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.
- package/README.md +2 -2
- package/dist/__tests__/unit/docs-config.test.js +9 -0
- package/dist/__tests__/unit/docs-config.test.js.map +1 -1
- package/dist/__tests__/unit/migrate-mdx.test.js +24 -0
- package/dist/__tests__/unit/migrate-mdx.test.js.map +1 -1
- package/dist/__tests__/unit/migrate-resolve-theme.test.js +6 -0
- package/dist/__tests__/unit/migrate-resolve-theme.test.js.map +1 -1
- package/dist/__tests__/unit/prompt-vendoring.test.d.ts +2 -0
- package/dist/__tests__/unit/prompt-vendoring.test.d.ts.map +1 -0
- package/dist/__tests__/unit/prompt-vendoring.test.js +20 -0
- package/dist/__tests__/unit/prompt-vendoring.test.js.map +1 -0
- package/dist/__tests__/unit/vendored-sync.test.js +3 -0
- package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
- package/dist/commands/migrate/types.d.ts +1 -1
- package/dist/commands/migrate/types.d.ts.map +1 -1
- package/dist/commands/migrate/types.js +5 -0
- package/dist/commands/migrate/types.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/deps.js +1 -1
- package/dist/lib/docs-config.d.ts +1 -1
- package/dist/lib/docs-config.d.ts.map +1 -1
- package/dist/lib/docs-config.js +1 -1
- package/dist/lib/docs-config.js.map +1 -1
- package/dist/lib/normalize-config.js +1 -1
- package/dist/lib/normalize-config.js.map +1 -1
- package/package.json +2 -2
- package/templates/quickstart.mdx +1 -1
- package/vendored/components/AIActionsMenu.tsx +5 -17
- package/vendored/components/mdx/Card.tsx +1 -0
- package/vendored/components/mdx/MDXComponents.tsx +2 -0
- package/vendored/components/mdx/Prompt.tsx +381 -0
- package/vendored/components/mdx/ai-action-icons.tsx +7 -0
- package/vendored/components/mdx/prompt-actions.ts +137 -0
- package/vendored/components/navigation/Sidebar.tsx +23 -14
- package/vendored/lib/ai-service-urls.ts +8 -0
- package/vendored/lib/build/error-parser.ts +1 -1
- package/vendored/lib/docs-types.ts +3 -3
- package/vendored/lib/extract-highlights.ts +1 -1
- package/vendored/lib/inject-prompt-source.ts +259 -0
- package/vendored/lib/layout-helpers.tsx +30 -1
- package/vendored/lib/mdx-syntax-fixes.ts +143 -0
- package/vendored/lib/mdx.ts +2 -1
- package/vendored/lib/normalize-config.ts +1 -1
- package/vendored/lib/preprocess-mdx.ts +10 -223
- package/vendored/lib/prompt-source-codec.ts +27 -0
- package/vendored/lib/render-doc-page.tsx +4 -1
- package/vendored/lib/snippet-loader-isr.ts +2 -0
- package/vendored/lib/validate-config.ts +4 -4
- package/vendored/schema/docs-schema.json +122 -1
- package/vendored/themes/halo/variables.css +514 -0
- package/vendored/themes/index.ts +26 -0
- package/vendored/themes/types.ts +1 -0
- 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.
|
|
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
|
},
|
package/templates/quickstart.mdx
CHANGED
|
@@ -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 `
|
|
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 =
|
|
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
|
+
}
|