jamdesk 1.1.170 → 1.1.172

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 (54) hide show
  1. package/README.md +2 -2
  2. package/dist/__tests__/unit/docs-config.test.js +9 -0
  3. package/dist/__tests__/unit/docs-config.test.js.map +1 -1
  4. package/dist/__tests__/unit/migrate-mdx.test.js +24 -0
  5. package/dist/__tests__/unit/migrate-mdx.test.js.map +1 -1
  6. package/dist/__tests__/unit/migrate-resolve-theme.test.js +6 -0
  7. package/dist/__tests__/unit/migrate-resolve-theme.test.js.map +1 -1
  8. package/dist/__tests__/unit/prompt-vendoring.test.d.ts +2 -0
  9. package/dist/__tests__/unit/prompt-vendoring.test.d.ts.map +1 -0
  10. package/dist/__tests__/unit/prompt-vendoring.test.js +20 -0
  11. package/dist/__tests__/unit/prompt-vendoring.test.js.map +1 -0
  12. package/dist/__tests__/unit/vendored-sync.test.js +3 -0
  13. package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
  14. package/dist/commands/migrate/types.d.ts +1 -1
  15. package/dist/commands/migrate/types.d.ts.map +1 -1
  16. package/dist/commands/migrate/types.js +5 -0
  17. package/dist/commands/migrate/types.js.map +1 -1
  18. package/dist/index.js +2 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/lib/deps.js +1 -1
  21. package/dist/lib/docs-config.d.ts +1 -1
  22. package/dist/lib/docs-config.d.ts.map +1 -1
  23. package/dist/lib/docs-config.js +1 -1
  24. package/dist/lib/docs-config.js.map +1 -1
  25. package/dist/lib/normalize-config.js +1 -1
  26. package/dist/lib/normalize-config.js.map +1 -1
  27. package/package.json +2 -2
  28. package/templates/quickstart.mdx +1 -1
  29. package/vendored/components/AIActionsMenu.tsx +5 -17
  30. package/vendored/components/mdx/Card.tsx +1 -0
  31. package/vendored/components/mdx/MDXComponents.tsx +2 -0
  32. package/vendored/components/mdx/Prompt.tsx +381 -0
  33. package/vendored/components/mdx/ai-action-icons.tsx +7 -0
  34. package/vendored/components/mdx/prompt-actions.ts +137 -0
  35. package/vendored/components/navigation/Sidebar.tsx +23 -14
  36. package/vendored/lib/ai-service-urls.ts +8 -0
  37. package/vendored/lib/build/error-parser.ts +1 -1
  38. package/vendored/lib/docs-types.ts +3 -3
  39. package/vendored/lib/extract-highlights.ts +1 -1
  40. package/vendored/lib/inject-prompt-source.ts +259 -0
  41. package/vendored/lib/layout-helpers.tsx +30 -1
  42. package/vendored/lib/mdx-syntax-fixes.ts +143 -0
  43. package/vendored/lib/mdx.ts +2 -1
  44. package/vendored/lib/normalize-config.ts +1 -1
  45. package/vendored/lib/preprocess-mdx.ts +10 -223
  46. package/vendored/lib/prompt-source-codec.ts +27 -0
  47. package/vendored/lib/render-doc-page.tsx +4 -1
  48. package/vendored/lib/snippet-loader-isr.ts +2 -0
  49. package/vendored/lib/validate-config.ts +4 -4
  50. package/vendored/schema/docs-schema.json +122 -1
  51. package/vendored/themes/halo/variables.css +514 -0
  52. package/vendored/themes/index.ts +26 -0
  53. package/vendored/themes/types.ts +1 -0
  54. package/vendored/workspace-package-lock.json +85 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.1.170",
3
+ "version": "1.1.172",
4
4
  "description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
5
5
  "keywords": [
6
6
  "jamdesk",
@@ -96,7 +96,7 @@
96
96
  "publish:dry": "node scripts/publish.js npm --dry-run",
97
97
  "test": "vitest",
98
98
  "test:local": "node scripts/test-local.js",
99
- "lint": "eslint src/",
99
+ "lint": "npm --prefix ../build-service exec -- eslint --config ../build-service/eslint.config.mjs src/",
100
100
  "dev": "tsc --watch",
101
101
  "postinstall": "node scripts/patch-openapi-schemas.js && node scripts/install-banner.js"
102
102
  },
@@ -73,7 +73,7 @@ Everything is configured in `docs.json`:
73
73
  | `name` | Site name shown in the header |
74
74
  | `colors` | Primary, light, and dark accent colors |
75
75
  | `logo` | Light and dark mode logo images |
76
- | `theme` | Visual theme (`jam`, `nebula`, or `pulsar`) |
76
+ | `theme` | Visual theme (`jam`, `nebula`, `pulsar`, or `halo`) |
77
77
  | `navigation` | Sidebar tabs, groups, and page order |
78
78
  | `navbar` | Top navigation links and buttons |
79
79
 
@@ -1,8 +1,10 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
4
+ import { CursorIcon } from '@/components/mdx/ai-action-icons';
4
5
  import { useOnClickOutside } from '@/hooks/useOnClickOutside';
5
6
  import { usePathname } from 'next/navigation';
7
+ import { AI_CHAT_URLS } from '@/lib/ai-service-urls';
6
8
  import { useLinkPrefix } from '@/lib/link-prefix-context';
7
9
  import { getIconClass } from '@/lib/icon-utils';
8
10
  import type { ContextualOption } from '@/lib/docs-types';
@@ -140,14 +142,6 @@ function GeminiIcon({ className }: { className?: string }) {
140
142
  );
141
143
  }
142
144
 
143
- function CursorIcon({ className }: { className?: string }) {
144
- return (
145
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={className}>
146
- <path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23" />
147
- </svg>
148
- );
149
- }
150
-
151
145
  function VSCodeIcon({ className }: { className?: string }) {
152
146
  return (
153
147
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="currentColor" className={className}>
@@ -284,14 +278,6 @@ function buildMcpConfig(projectName: string, mcpUrl: string) {
284
278
  };
285
279
  }
286
280
 
287
- /** Base URLs for AI chat services that support prompt pre-filling. */
288
- const AI_CHAT_URLS: Record<string, string> = {
289
- chatgpt: 'https://chatgpt.com/?hints=search&q=',
290
- claude: 'https://claude.ai/new?q=',
291
- gemini: 'https://gemini.google.com/app?q=',
292
- perplexity: 'https://www.perplexity.ai/?q=',
293
- };
294
-
295
281
  /** Hook that resets a feedback state to 'idle' after 2 seconds. */
296
282
  function useFeedbackTimer(state: FeedbackState, setState: (s: FeedbackState) => void) {
297
283
  useEffect(() => {
@@ -469,7 +455,9 @@ export function AIActionsMenu({ options, projectName }: AIActionsMenuProps) {
469
455
  case 'claude':
470
456
  case 'gemini':
471
457
  case 'perplexity': {
472
- const baseUrl = AI_CHAT_URLS[option];
458
+ const baseUrl = option === 'chatgpt'
459
+ ? AI_CHAT_URLS.chatgptSearch
460
+ : AI_CHAT_URLS[option];
473
461
  window.open(
474
462
  `${baseUrl}${encodeURIComponent(`Read this page and answer my questions about it: ${mdAbsoluteUrl}`)}`,
475
463
  '_blank', 'noopener,noreferrer'
@@ -28,6 +28,7 @@ export const Card = memo(function Card({ title, icon, img, href, children, horiz
28
28
  className="group"
29
29
  style={{
30
30
  position: 'relative',
31
+ backgroundColor: 'var(--card-bg, transparent)',
31
32
  borderRadius: 'var(--radius-lg)',
32
33
  border: 'var(--border-width, 1px) solid var(--color-border)',
33
34
  overflow: 'hidden',
@@ -19,6 +19,7 @@ import { ParamField } from './ParamField';
19
19
  import { ResponseField } from './ResponseField';
20
20
  import { RequestExample } from './RequestExample';
21
21
  import { ResponseExample } from './ResponseExample';
22
+ import { Prompt } from './Prompt';
22
23
  import { ApiEndpoint, HttpMethodBadge } from './ApiEndpoint';
23
24
  import { ApiCodePanel, ApiPageLayout } from './ApiCodePanel';
24
25
  import { OpenApiEndpoint } from './OpenApiEndpoint';
@@ -198,6 +199,7 @@ export const MDXComponents = {
198
199
  ResponseField,
199
200
  RequestExample,
200
201
  ResponseExample,
202
+ Prompt,
201
203
  ApiEndpoint,
202
204
  HttpMethodBadge,
203
205
  ApiCodePanel,
@@ -0,0 +1,381 @@
1
+ 'use client';
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useId,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type KeyboardEvent as ReactKeyboardEvent,
11
+ type ReactElement,
12
+ type ReactNode,
13
+ } from 'react';
14
+
15
+ import { useOnClickOutside } from '@/hooks/useOnClickOutside';
16
+ import { renderInlineMarkdown } from '@/lib/inline-markdown';
17
+ import { decodePromptSource } from '@/lib/prompt-source-codec';
18
+
19
+ import { CursorIcon } from './ai-action-icons';
20
+ import { Icon } from './Icon';
21
+ import {
22
+ resolvePromptActions,
23
+ type PromptAction,
24
+ type ResolvedPromptAction,
25
+ } from './prompt-actions';
26
+
27
+ type IconType = 'regular' | 'solid' | 'light' | 'thin' | 'sharp-solid' | 'duotone' | 'brands';
28
+ type CopyState = 'idle' | 'copied' | 'error';
29
+
30
+ const EMPTY_ACTIONS: PromptAction[] = [];
31
+
32
+ export interface PromptProps {
33
+ children?: ReactNode;
34
+ title?: string;
35
+ description?: string;
36
+ icon?: string | ReactElement;
37
+ iconType?: IconType;
38
+ actions?: PromptAction[];
39
+ singleLine?: boolean | string;
40
+ hidePrompt?: boolean | string;
41
+ className?: string;
42
+ /** UTF-8/base64, injected by injectPromptSources; not author-facing. */
43
+ __promptSource?: string;
44
+ }
45
+
46
+ function isImageIcon(icon: string): boolean {
47
+ return /^(?:https?:\/\/|\/|\.\/)/i.test(icon);
48
+ }
49
+
50
+ function resolveAuthorIcon(icon: string | ReactElement, iconType?: IconType): string | ReactElement {
51
+ if (typeof icon !== 'string' || !iconType || isImageIcon(icon) || icon.includes('/')) {
52
+ return icon;
53
+ }
54
+ return `${iconType}/${icon}`;
55
+ }
56
+
57
+ function ActionIcon({ action }: { action: ResolvedPromptAction['action'] }): ReactElement | null {
58
+ if (action === 'cursor') {
59
+ return <CursorIcon className="h-4 w-4 shrink-0" />;
60
+ }
61
+ if (action === 'claude') {
62
+ return <Icon icon="brain" size={16} className="shrink-0" />;
63
+ }
64
+ if (action === 'chatgpt') {
65
+ return <Icon icon="brands/openai" size={16} className="shrink-0" />;
66
+ }
67
+ if (typeof action !== 'string' && action.icon) {
68
+ return <Icon icon={action.icon} size={16} className="shrink-0" />;
69
+ }
70
+ return null;
71
+ }
72
+
73
+ export function Prompt({
74
+ children,
75
+ title,
76
+ description,
77
+ icon = 'sparkles',
78
+ iconType,
79
+ actions = EMPTY_ACTIONS,
80
+ singleLine = false,
81
+ hidePrompt = false,
82
+ className,
83
+ __promptSource = '',
84
+ }: PromptProps): ReactElement {
85
+ const [copyState, setCopyState] = useState<CopyState>('idle');
86
+ const [menuOpen, setMenuOpen] = useState(false);
87
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
88
+ const copyAttemptRef = useRef(0);
89
+ const mountedRef = useRef(false);
90
+ const menuBoundaryRef = useRef<HTMLDivElement>(null);
91
+ const triggerRef = useRef<HTMLButtonElement>(null);
92
+ const itemRefs = useRef<Array<HTMLAnchorElement | null>>([]);
93
+ const generatedMenuId = useId();
94
+ const menuId = `prompt-actions-${generatedMenuId}`;
95
+
96
+ const prompt = useMemo(() => decodePromptSource(__promptSource), [__promptSource]);
97
+ const hasPrompt = prompt.length > 0;
98
+ const resolvedActions = useMemo(
99
+ () => hasPrompt ? resolvePromptActions(actions, prompt) : [],
100
+ [actions, hasPrompt, prompt],
101
+ );
102
+ const [primary, ...secondary] = resolvedActions;
103
+
104
+ const isSingleLine = singleLine === true || singleLine === 'true';
105
+ const hasTitle = Boolean(title?.trim());
106
+ const shouldHidePrompt = hasTitle && (hidePrompt === true || hidePrompt === 'true');
107
+ const authorIcon = resolveAuthorIcon(icon, iconType);
108
+
109
+ const closeMenu = useCallback(() => {
110
+ setMenuOpen(false);
111
+ }, []);
112
+
113
+ const closeMenuAndFocusTrigger = useCallback(() => {
114
+ setMenuOpen(false);
115
+ triggerRef.current?.focus();
116
+ }, []);
117
+
118
+ useOnClickOutside(menuBoundaryRef, closeMenu, menuOpen);
119
+
120
+ useEffect(() => {
121
+ mountedRef.current = true;
122
+ return () => {
123
+ mountedRef.current = false;
124
+ copyAttemptRef.current += 1;
125
+ if (timerRef.current) clearTimeout(timerRef.current);
126
+ };
127
+ }, []);
128
+
129
+ useEffect(() => {
130
+ if (menuOpen) itemRefs.current[0]?.focus();
131
+ }, [menuOpen]);
132
+
133
+ const copyPrompt = useCallback(async () => {
134
+ if (!hasPrompt) return;
135
+
136
+ const attempt = ++copyAttemptRef.current;
137
+ if (timerRef.current) {
138
+ clearTimeout(timerRef.current);
139
+ timerRef.current = null;
140
+ }
141
+
142
+ let nextState: Exclude<CopyState, 'idle'>;
143
+ try {
144
+ if (!navigator.clipboard?.writeText) {
145
+ throw new Error('Clipboard unavailable');
146
+ }
147
+ await navigator.clipboard.writeText(prompt);
148
+ nextState = 'copied';
149
+ } catch {
150
+ nextState = 'error';
151
+ }
152
+
153
+ if (!mountedRef.current || attempt !== copyAttemptRef.current) return;
154
+ setCopyState(nextState);
155
+ timerRef.current = setTimeout(() => {
156
+ if (mountedRef.current && attempt === copyAttemptRef.current) {
157
+ setCopyState('idle');
158
+ }
159
+ }, 2000);
160
+ }, [hasPrompt, prompt]);
161
+
162
+ const handleMenuKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
163
+ if (event.key === 'Escape') {
164
+ event.preventDefault();
165
+ closeMenuAndFocusTrigger();
166
+ return;
167
+ }
168
+ if (
169
+ (event.key === ' ' || event.key === 'Spacebar') &&
170
+ event.target instanceof HTMLAnchorElement &&
171
+ event.target.getAttribute('role') === 'menuitem'
172
+ ) {
173
+ event.preventDefault();
174
+ event.target.click();
175
+ return;
176
+ }
177
+
178
+ const itemCount = secondary.length;
179
+ if (itemCount === 0) return;
180
+ if (event.key === 'Home' || event.key === 'End') {
181
+ event.preventDefault();
182
+ const targetIndex = event.key === 'Home' ? 0 : itemCount - 1;
183
+ itemRefs.current[targetIndex]?.focus();
184
+ return;
185
+ }
186
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
187
+
188
+ event.preventDefault();
189
+ const activeIndex = itemRefs.current.findIndex((item) => item === document.activeElement);
190
+ const direction = event.key === 'ArrowDown' ? 1 : -1;
191
+ const nextIndex = (Math.max(activeIndex, 0) + direction + itemCount) % itemCount;
192
+ itemRefs.current[nextIndex]?.focus();
193
+ }, [closeMenuAndFocusTrigger, secondary.length]);
194
+
195
+ const rootClassName = [
196
+ 'not-prose my-4 min-w-0 max-w-full overflow-visible',
197
+ 'rounded-[var(--radius-lg)] border border-[var(--color-border)]',
198
+ 'bg-[var(--color-bg-secondary)] text-[var(--color-text-primary)]',
199
+ 'shadow-[var(--shadow-sm)]',
200
+ className,
201
+ ].filter(Boolean).join(' ');
202
+
203
+ const actionClassName = [
204
+ 'inline-flex min-w-0 max-w-full items-center gap-2 rounded-[var(--radius-lg)]',
205
+ 'border border-[var(--color-border)] px-3 py-2 text-sm font-medium',
206
+ 'text-[var(--color-text-primary)] transition-colors cursor-pointer',
207
+ 'hover:border-[var(--color-primary)] hover:text-[var(--color-primary)]',
208
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]',
209
+ 'disabled:cursor-not-allowed disabled:opacity-50',
210
+ ].join(' ');
211
+
212
+ return (
213
+ <section data-testid="prompt-card" className={rootClassName}>
214
+ <div
215
+ data-testid={!hasTitle ? 'prompt-body-row' : undefined}
216
+ className={hasTitle
217
+ ? 'contents'
218
+ : 'flex min-w-0 max-w-full flex-wrap items-start gap-3 px-4 py-4'}
219
+ >
220
+ <div
221
+ data-testid={hasTitle ? 'prompt-header' : undefined}
222
+ className={hasTitle
223
+ ? 'flex min-w-0 max-w-full items-center gap-3 px-4 pt-4'
224
+ : 'contents'}
225
+ >
226
+ <span
227
+ data-testid="prompt-icon"
228
+ className="shrink-0 text-[var(--color-primary)]"
229
+ aria-hidden="true"
230
+ >
231
+ <Icon icon={authorIcon} size={18} />
232
+ </span>
233
+ {hasTitle ? (
234
+ <h3 className="m-0 min-w-0 max-w-full break-words [overflow-wrap:anywhere] text-base font-semibold text-[var(--color-text-primary)]">
235
+ {title}
236
+ </h3>
237
+ ) : null}
238
+ </div>
239
+
240
+ {!shouldHidePrompt ? (
241
+ <div
242
+ data-testid="prompt-preview"
243
+ className={`prompt-preview min-w-0 max-w-full text-sm leading-6 text-[var(--color-text-tertiary)]${hasTitle ? ' mx-4 mt-3' : ' flex-1'}${isSingleLine ? ' prompt-preview-single-line' : ''}`}
244
+ >
245
+ {description !== undefined ? renderInlineMarkdown(description) : children}
246
+ </div>
247
+ ) : null}
248
+
249
+ <div
250
+ data-testid="prompt-actions"
251
+ className={`flex min-w-0 max-w-full flex-wrap items-center gap-2${hasTitle ? ' px-4 pb-4 pt-3' : ' basis-full sm:ml-auto sm:basis-auto'}`}
252
+ >
253
+ <button
254
+ type="button"
255
+ aria-label="Copy prompt"
256
+ className={actionClassName}
257
+ data-copy-state={copyState}
258
+ disabled={!hasPrompt}
259
+ onClick={copyPrompt}
260
+ >
261
+ <i className="fa-regular fa-copy shrink-0" aria-hidden="true" />
262
+ <span>{copyState === 'copied' ? 'Copied' : copyState === 'error' ? 'Copy failed' : 'Copy'}</span>
263
+ </button>
264
+
265
+ <span role="status" aria-live="polite" className="sr-only">
266
+ {copyState === 'copied' ? 'Copied' : copyState === 'error' ? 'Copy failed' : ''}
267
+ </span>
268
+
269
+ {primary ? (
270
+ <a
271
+ href={primary.href}
272
+ target="_blank"
273
+ rel="noopener noreferrer"
274
+ className={actionClassName}
275
+ aria-label={primary.label}
276
+ >
277
+ <span aria-hidden="true"><ActionIcon action={primary.action} /></span>
278
+ <span className="min-w-0 truncate">{primary.label}</span>
279
+ </a>
280
+ ) : null}
281
+
282
+ {secondary.length > 0 ? (
283
+ <div
284
+ ref={menuBoundaryRef}
285
+ className="relative flex min-w-0 max-w-full basis-full w-full shrink-0 justify-end sm:ml-auto sm:basis-auto sm:w-auto sm:shrink"
286
+ >
287
+ <button
288
+ ref={triggerRef}
289
+ type="button"
290
+ aria-label="More prompt actions"
291
+ aria-haspopup="menu"
292
+ aria-expanded={menuOpen}
293
+ aria-controls={menuId}
294
+ className={actionClassName}
295
+ onMouseDown={(event) => {
296
+ if (menuOpen) event.preventDefault();
297
+ }}
298
+ onClick={() => {
299
+ setMenuOpen((open) => !open);
300
+ if (menuOpen) triggerRef.current?.focus();
301
+ }}
302
+ >
303
+ <span>More</span>
304
+ <i className="fa-solid fa-chevron-down text-xs" aria-hidden="true" />
305
+ </button>
306
+
307
+ {menuOpen ? (
308
+ <div
309
+ id={menuId}
310
+ role="menu"
311
+ aria-label="More prompt actions"
312
+ className="absolute top-full right-0 z-20 mt-2 w-max max-w-[min(20rem,calc(100vw-2rem))] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-bg-secondary)] p-1 shadow-[var(--shadow-sm)]"
313
+ onKeyDown={handleMenuKeyDown}
314
+ onBlur={(event) => {
315
+ if (!event.currentTarget.contains(event.relatedTarget)) {
316
+ closeMenu();
317
+ }
318
+ }}
319
+ >
320
+ {secondary.map((action, index) => (
321
+ <a
322
+ key={`${action.label}-${action.href}-${index}`}
323
+ ref={(node) => { itemRefs.current[index] = node; }}
324
+ role="menuitem"
325
+ tabIndex={-1}
326
+ href={action.href}
327
+ target="_blank"
328
+ rel="noopener noreferrer"
329
+ aria-label={action.label}
330
+ className="flex min-w-0 max-w-full cursor-pointer items-center gap-2 rounded-[var(--radius-lg)] px-3 py-2 text-sm text-[var(--color-text-primary)] hover:bg-[var(--color-border)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]"
331
+ onClick={closeMenuAndFocusTrigger}
332
+ >
333
+ <span aria-hidden="true"><ActionIcon action={action.action} /></span>
334
+ <span className="min-w-0 truncate">{action.label}</span>
335
+ </a>
336
+ ))}
337
+ </div>
338
+ ) : null}
339
+ </div>
340
+ ) : null}
341
+ </div>
342
+ </div>
343
+
344
+ <style jsx>{`
345
+ .prompt-preview {
346
+ max-height: 7.5rem;
347
+ overflow-y: auto;
348
+ overflow-wrap: anywhere;
349
+ }
350
+ .prompt-preview :global(p) {
351
+ margin: 0 0 0.75rem;
352
+ }
353
+ .prompt-preview :global(p:last-child) {
354
+ margin-bottom: 0;
355
+ }
356
+ .prompt-preview :global(ul),
357
+ .prompt-preview :global(ol) {
358
+ margin: 0.5rem 0;
359
+ padding-left: 1.5rem;
360
+ }
361
+ .prompt-preview.prompt-preview-single-line {
362
+ max-height: 1.5rem;
363
+ overflow: hidden;
364
+ white-space: nowrap;
365
+ text-overflow: ellipsis;
366
+ }
367
+ .prompt-preview.prompt-preview-single-line > :global(*),
368
+ .prompt-preview.prompt-preview-single-line > :global(ul) > :global(li),
369
+ .prompt-preview.prompt-preview-single-line > :global(ol) > :global(li) {
370
+ max-width: 100%;
371
+ max-height: 1.5rem;
372
+ margin-top: 0;
373
+ margin-bottom: 0;
374
+ overflow: hidden;
375
+ white-space: nowrap;
376
+ text-overflow: ellipsis;
377
+ }
378
+ `}</style>
379
+ </section>
380
+ );
381
+ }
@@ -0,0 +1,7 @@
1
+ export function CursorIcon({ className }: { className?: string }) {
2
+ return (
3
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={className}>
4
+ <path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23" />
5
+ </svg>
6
+ );
7
+ }
@@ -0,0 +1,137 @@
1
+ import { AI_CHAT_URLS } from '@/lib/ai-service-urls';
2
+
3
+ export type PromptBuiltinAction = 'copy' | 'cursor' | 'claude' | 'chatgpt';
4
+
5
+ export interface PromptCustomAction {
6
+ label: string;
7
+ url: string;
8
+ icon?: string;
9
+ }
10
+
11
+ export type PromptAction = PromptBuiltinAction | PromptCustomAction;
12
+ export type PromptOpenAction = Exclude<PromptAction, 'copy'>;
13
+
14
+ export const MAX_PROMPT_ACTION_URL_LENGTH = 8_000;
15
+
16
+ const BASES: Record<Exclude<PromptBuiltinAction, 'copy'>, string> = {
17
+ cursor: AI_CHAT_URLS.cursor,
18
+ claude: AI_CHAT_URLS.claude,
19
+ chatgpt: AI_CHAT_URLS.chatgpt,
20
+ };
21
+
22
+ export function normalizePromptActions(actions: unknown): PromptOpenAction[] {
23
+ try {
24
+ return normalizePromptActionsUnsafe(actions);
25
+ } catch {
26
+ // Array checks and iteration can themselves trap on hostile proxies.
27
+ return [];
28
+ }
29
+ }
30
+
31
+ function normalizePromptActionsUnsafe(actions: unknown): PromptOpenAction[] {
32
+ if (!Array.isArray(actions)) return [];
33
+
34
+ const seen = new Set<string>();
35
+ const normalized: PromptOpenAction[] = [];
36
+
37
+ for (const action of actions) {
38
+ if (action === 'copy') continue;
39
+
40
+ if (typeof action === 'string') {
41
+ if (!['cursor', 'claude', 'chatgpt'].includes(action) || seen.has(action)) continue;
42
+ seen.add(action);
43
+ normalized.push(action as Exclude<PromptBuiltinAction, 'copy'>);
44
+ continue;
45
+ }
46
+
47
+ if (typeof action !== 'object' || action === null) continue;
48
+
49
+ // MDX expressions are runtime input, so accept only own data properties
50
+ // on plain records and contain proxy/accessor traps per item.
51
+ try {
52
+ if (Array.isArray(action)) continue;
53
+ const prototype = Object.getPrototypeOf(action);
54
+ if (prototype !== Object.prototype && prototype !== null) continue;
55
+
56
+ const descriptors = Object.getOwnPropertyDescriptors(action);
57
+ const labelValue = descriptors.label?.value;
58
+ const urlValue = descriptors.url?.value;
59
+ const iconValue = descriptors.icon?.value;
60
+ if (typeof labelValue !== 'string' || typeof urlValue !== 'string') continue;
61
+ if (descriptors.icon && typeof iconValue !== 'string') continue;
62
+
63
+ const label = labelValue.trim();
64
+ const url = urlValue.trim();
65
+ if (!label || !url) continue;
66
+
67
+ const normalizedAction: PromptCustomAction = {
68
+ label,
69
+ url,
70
+ ...(iconValue === undefined ? {} : { icon: iconValue }),
71
+ };
72
+ const key = `${label}\u0000${url}`;
73
+ if (seen.has(key)) continue;
74
+ seen.add(key);
75
+ normalized.push(normalizedAction);
76
+ } catch {
77
+ continue;
78
+ }
79
+ }
80
+
81
+ return normalized;
82
+ }
83
+
84
+ export function buildPromptActionUrl(action: PromptOpenAction, prompt: string): string | null {
85
+ if (typeof action === 'string') {
86
+ const href = `${BASES[action]}${encodeURIComponent(prompt)}`;
87
+ return href.length <= MAX_PROMPT_ACTION_URL_LENGTH ? href : null;
88
+ }
89
+
90
+ try {
91
+ const placeholder = '__jamdesk_prompt__';
92
+ if (action.url.includes(placeholder)) return null;
93
+
94
+ const hasPlaceholder = action.url.includes('{prompt}');
95
+ const url = new URL(action.url.replaceAll('{prompt}', placeholder));
96
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null;
97
+ if (url.origin.includes(placeholder)) return null;
98
+ if ([...url.searchParams.keys()].some((key) => key.includes(placeholder))) return null;
99
+
100
+ let href: string;
101
+ if (hasPlaceholder) {
102
+ href = url.toString().replaceAll(placeholder, encodeURIComponent(prompt));
103
+ } else {
104
+ url.searchParams.set('prompt', prompt);
105
+ href = url.toString();
106
+ }
107
+
108
+ return href.length <= MAX_PROMPT_ACTION_URL_LENGTH ? href : null;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ export interface ResolvedPromptAction {
115
+ action: PromptOpenAction;
116
+ href: string;
117
+ label: string;
118
+ }
119
+
120
+ const BUILTIN_META = {
121
+ cursor: { label: 'Open in Cursor' },
122
+ claude: { label: 'Open in Claude' },
123
+ chatgpt: { label: 'Open in ChatGPT' },
124
+ } as const;
125
+
126
+ export function resolvePromptActions(actions: unknown, prompt: string): ResolvedPromptAction[] {
127
+ return normalizePromptActions(actions).flatMap((action) => {
128
+ const href = buildPromptActionUrl(action, prompt);
129
+ if (!href) return [];
130
+
131
+ return [{
132
+ action,
133
+ href,
134
+ label: typeof action === 'string' ? BUILTIN_META[action].label : action.label,
135
+ }];
136
+ });
137
+ }