@aravindc26/velu 0.12.8 → 0.12.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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/build.ts +13 -0
  3. package/src/cli.ts +51 -9
  4. package/src/engine/app/(docs)/[...slug]/layout.tsx +21 -537
  5. package/src/engine/app/_preview/[sessionId]/[...slug]/layout.tsx +96 -0
  6. package/src/engine/app/_preview/[sessionId]/[...slug]/page.tsx +298 -0
  7. package/src/engine/app/_preview/[sessionId]/layout.tsx +56 -0
  8. package/src/{preview-engine/app → engine/app/_preview}/[sessionId]/page.tsx +7 -3
  9. package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/assets/[...path]/route.ts +1 -1
  10. package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/init/route.ts +2 -2
  11. package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/route.ts +3 -3
  12. package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/sync/route.ts +2 -2
  13. package/src/{preview-engine/app → engine/app/_preview}/layout.tsx +4 -1
  14. package/src/engine/app/global.css +0 -3623
  15. package/src/engine/app/layout.tsx +4 -3
  16. package/src/engine/components/sidebar-links.tsx +11 -5
  17. package/src/engine/lib/docs-layout.tsx +605 -0
  18. package/src/engine/lib/layout.shared.ts +7 -7
  19. package/src/engine/lib/preview-config.ts +129 -0
  20. package/src/{preview-engine/lib/content-generator.ts → engine/lib/preview-content.ts} +242 -42
  21. package/src/engine/lib/source.ts +80 -97
  22. package/src/engine/lib/velu.ts +79 -55
  23. package/src/engine/mdx-components.tsx +14 -650
  24. package/src/engine/source.config.ts +11 -89
  25. package/src/engine/tsconfig.json +1 -0
  26. package/src/engine-core/components/assistant.tsx +361 -0
  27. package/src/engine-core/components/banner.tsx +80 -0
  28. package/src/engine-core/components/changelog-filters.tsx +114 -0
  29. package/src/engine-core/components/code-group.tsx +383 -0
  30. package/src/engine-core/components/color.tsx +118 -0
  31. package/src/engine-core/components/copy-page.tsx +223 -0
  32. package/src/engine-core/components/dropdown-switcher.tsx +142 -0
  33. package/src/engine-core/components/expandable.tsx +77 -0
  34. package/src/engine-core/components/header-tab-link.tsx +43 -0
  35. package/src/engine-core/components/icon.tsx +136 -0
  36. package/src/engine-core/components/image-zoom-fallback.tsx +147 -0
  37. package/src/engine-core/components/image.tsx +111 -0
  38. package/src/engine-core/components/lang-switcher.tsx +101 -0
  39. package/src/engine-core/components/manual-api-playground.tsx +154 -0
  40. package/src/engine-core/components/mermaid.tsx +142 -0
  41. package/src/engine-core/components/openapi-toc-sync.tsx +59 -0
  42. package/src/engine-core/components/openapi.tsx +1682 -0
  43. package/src/engine-core/components/page-feedback-api.test.ts +83 -0
  44. package/src/engine-core/components/page-feedback-api.ts +89 -0
  45. package/src/engine-core/components/page-feedback.tsx +200 -0
  46. package/src/engine-core/components/product-switcher.tsx +107 -0
  47. package/src/engine-core/components/prompt.tsx +90 -0
  48. package/src/engine-core/components/providers.tsx +21 -0
  49. package/src/engine-core/components/search.tsx +318 -0
  50. package/src/engine-core/components/sidebar-links.tsx +54 -0
  51. package/src/engine-core/components/synced-tabs.tsx +57 -0
  52. package/src/engine-core/components/theme-toggle.tsx +39 -0
  53. package/src/engine-core/components/toc-examples.tsx +110 -0
  54. package/src/engine-core/components/version-switcher.tsx +95 -0
  55. package/src/engine-core/components/view.tsx +344 -0
  56. package/src/engine-core/css/assistant.css +326 -0
  57. package/src/engine-core/css/copy-page.css +206 -0
  58. package/src/engine-core/css/search.css +142 -0
  59. package/src/engine-core/css/shared.css +3628 -0
  60. package/src/engine-core/lib/remark-plugins.ts +102 -0
  61. package/src/engine-core/lib/source-plugins.ts +105 -0
  62. package/src/engine-core/mdx-components.tsx +654 -0
  63. package/src/engine-core/types.ts +49 -0
  64. package/src/preview-engine/app/[sessionId]/[...slug]/page.tsx +0 -41
  65. package/src/preview-engine/app/[sessionId]/layout.tsx +0 -26
  66. package/src/preview-engine/app/global.css +0 -29
  67. package/src/preview-engine/lib/session-config.ts +0 -86
  68. package/src/preview-engine/lib/session-layout.ts +0 -190
  69. package/src/preview-engine/lib/source.ts +0 -60
  70. package/src/preview-engine/next.config.mjs +0 -20
  71. package/src/preview-engine/postcss.config.mjs +0 -8
  72. package/src/preview-engine/source.config.ts +0 -26
  73. package/src/preview-engine/tsconfig.json +0 -32
  74. package/src/preview-engine/tsconfig.tsbuildinfo +0 -1
  75. /package/src/{preview-engine/app → engine/app/_preview}/page.tsx +0 -0
  76. /package/src/{preview-engine/lib/auth.ts → engine/lib/preview-auth.ts} +0 -0
@@ -0,0 +1,83 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import {
4
+ buildPublicFeedbackPayload,
5
+ PUBLIC_API_BASE_URL,
6
+ PUBLIC_FEEDBACK_ENDPOINT_PATH,
7
+ resolvePublicFeedbackEndpoint,
8
+ submitPublicFeedback,
9
+ } from './page-feedback-api';
10
+
11
+ test('buildPublicFeedbackPayload returns contract body with optional fields', () => {
12
+ const payload = buildPublicFeedbackPayload({
13
+ pageUrl: 'https://docs.example.com/guide',
14
+ helpful: true,
15
+ reasonText: 'The guide worked as expected',
16
+ details: ' Extra details ',
17
+ email: ' user@example.com ',
18
+ });
19
+
20
+ assert.deepEqual(payload, {
21
+ page_url: 'https://docs.example.com/guide',
22
+ helpful: true,
23
+ reason_text: 'The guide worked as expected',
24
+ details: 'Extra details',
25
+ email: 'user@example.com',
26
+ });
27
+ });
28
+
29
+ test('resolvePublicFeedbackEndpoint appends the fixed endpoint path', () => {
30
+ assert.equal(
31
+ resolvePublicFeedbackEndpoint(),
32
+ `${PUBLIC_API_BASE_URL}${PUBLIC_FEEDBACK_ENDPOINT_PATH}`,
33
+ );
34
+ });
35
+
36
+ test('submitPublicFeedback posts the expected payload and headers', async () => {
37
+ let receivedUrl = '';
38
+ let receivedInit: RequestInit | undefined;
39
+
40
+ const fetchImpl: typeof fetch = async (input, init) => {
41
+ receivedUrl = String(input);
42
+ receivedInit = init;
43
+ return new Response(null, { status: 204 });
44
+ };
45
+
46
+ const result = await submitPublicFeedback({
47
+ pageUrl: 'https://docs.example.com/page',
48
+ helpful: false,
49
+ reasonText: 'Update this documentation',
50
+ details: '',
51
+ email: undefined,
52
+ siteHost: 'docs.example.com',
53
+ fetchImpl,
54
+ });
55
+
56
+ assert.deepEqual(result, { ok: true });
57
+ assert.equal(receivedUrl, `${PUBLIC_API_BASE_URL}${PUBLIC_FEEDBACK_ENDPOINT_PATH}`);
58
+ assert.equal(receivedInit?.method, 'POST');
59
+ assert.equal(receivedInit?.credentials, 'include');
60
+ assert.equal(receivedInit?.body, JSON.stringify({
61
+ page_url: 'https://docs.example.com/page',
62
+ helpful: false,
63
+ reason_text: 'Update this documentation',
64
+ }));
65
+
66
+ const headers = receivedInit?.headers as Record<string, string>;
67
+ assert.deepEqual(headers, {
68
+ 'Content-Type': 'application/json',
69
+ 'x-velu-site-host': 'docs.example.com',
70
+ });
71
+ });
72
+
73
+ test('submitPublicFeedback reports non-2xx responses as request_failed', async () => {
74
+ const result = await submitPublicFeedback({
75
+ pageUrl: 'https://docs.example.com/page',
76
+ helpful: true,
77
+ reasonText: 'Something else',
78
+ siteHost: 'docs.example.com',
79
+ fetchImpl: async () => new Response(null, { status: 500 }),
80
+ });
81
+
82
+ assert.deepEqual(result, { ok: false, reason: 'request_failed', status: 500 });
83
+ });
@@ -0,0 +1,89 @@
1
+ export const PUBLIC_FEEDBACK_ENDPOINT_PATH = '/api/v1/public/feedback';
2
+ export const PUBLIC_API_BASE_URL = 'https://api.getvelu.com';
3
+
4
+ export interface PublicFeedbackPayload {
5
+ page_url: string;
6
+ helpful: boolean;
7
+ reason_text: string;
8
+ details?: string;
9
+ email?: string;
10
+ }
11
+
12
+ interface BuildPayloadInput {
13
+ pageUrl: string;
14
+ helpful: boolean;
15
+ reasonText: string;
16
+ details?: string;
17
+ email?: string;
18
+ }
19
+
20
+ export interface SubmitPublicFeedbackInput extends BuildPayloadInput {
21
+ siteHost: string;
22
+ fetchImpl?: typeof fetch;
23
+ }
24
+
25
+ type SubmitErrorReason = 'invalid_payload' | 'request_failed' | 'network_error';
26
+
27
+ export type SubmitPublicFeedbackResult =
28
+ | { ok: true }
29
+ | { ok: false; reason: SubmitErrorReason; status?: number };
30
+
31
+ function trimOptional(value: string | undefined): string | undefined {
32
+ if (typeof value !== 'string') return undefined;
33
+ const trimmed = value.trim();
34
+ return trimmed.length > 0 ? trimmed : undefined;
35
+ }
36
+
37
+ export function resolvePublicFeedbackEndpoint(): string {
38
+ return `${PUBLIC_API_BASE_URL}${PUBLIC_FEEDBACK_ENDPOINT_PATH}`;
39
+ }
40
+
41
+ export function buildPublicFeedbackPayload(input: BuildPayloadInput): PublicFeedbackPayload | null {
42
+ const pageUrl = trimOptional(input.pageUrl);
43
+ const reasonText = trimOptional(input.reasonText);
44
+ if (!pageUrl || !reasonText) return null;
45
+
46
+ const payload: PublicFeedbackPayload = {
47
+ page_url: pageUrl,
48
+ helpful: input.helpful,
49
+ reason_text: reasonText,
50
+ };
51
+
52
+ const details = trimOptional(input.details);
53
+ if (details) payload.details = details;
54
+
55
+ const email = trimOptional(input.email);
56
+ if (email) payload.email = email;
57
+
58
+ return payload;
59
+ }
60
+
61
+ export async function submitPublicFeedback(input: SubmitPublicFeedbackInput): Promise<SubmitPublicFeedbackResult> {
62
+ const payload = buildPublicFeedbackPayload(input);
63
+ if (!payload) return { ok: false, reason: 'invalid_payload' };
64
+
65
+ const fetchImpl = input.fetchImpl ?? fetch;
66
+ try {
67
+ const response = await fetchImpl(resolvePublicFeedbackEndpoint(), {
68
+ method: 'POST',
69
+ credentials: 'include',
70
+ headers: {
71
+ 'Content-Type': 'application/json',
72
+ 'x-velu-site-host': input.siteHost,
73
+ },
74
+ body: JSON.stringify(payload),
75
+ });
76
+
77
+ if (!response.ok) {
78
+ return {
79
+ ok: false,
80
+ reason: 'request_failed',
81
+ status: response.status,
82
+ };
83
+ }
84
+
85
+ return { ok: true };
86
+ } catch {
87
+ return { ok: false, reason: 'network_error' };
88
+ }
89
+ }
@@ -0,0 +1,200 @@
1
+ 'use client';
2
+
3
+ import { useState, type MouseEvent as ReactMouseEvent } from 'react';
4
+ import { ThumbsDown, ThumbsUp } from 'lucide-react';
5
+ import { submitPublicFeedback } from './page-feedback-api';
6
+
7
+ type Vote = 'yes' | 'no';
8
+
9
+ const YES_OPTIONS = [
10
+ 'The guide worked as expected',
11
+ 'It was easy to find the information I needed',
12
+ 'It was easy to understand the product and features',
13
+ 'The documentation is up to date',
14
+ 'Something else',
15
+ ];
16
+
17
+ const NO_OPTIONS = [
18
+ 'Help me get started faster',
19
+ 'Make it easier to find what I\'m looking for',
20
+ 'Make it easy to understand the product and features',
21
+ 'Update this documentation',
22
+ 'Something else',
23
+ ];
24
+
25
+ export function PageFeedback() {
26
+ const [vote, setVote] = useState<Vote | null>(null);
27
+ const [selectedReason, setSelectedReason] = useState<string>('');
28
+ const [details, setDetails] = useState('');
29
+ const [email, setEmail] = useState('');
30
+ const [isSubmitting, setIsSubmitting] = useState(false);
31
+ const [submitError, setSubmitError] = useState(false);
32
+
33
+ const showForm = vote !== null;
34
+ const options = vote === 'yes' ? YES_OPTIONS : NO_OPTIONS;
35
+ const showOptionalInputs = selectedReason === 'Something else';
36
+ const canSubmit = vote !== null && selectedReason.trim().length > 0 && !isSubmitting;
37
+
38
+ const onChooseVote = (value: Vote) => {
39
+ if (vote === value) return;
40
+ setVote(value);
41
+ setSelectedReason('');
42
+ setDetails('');
43
+ setEmail('');
44
+ setSubmitError(false);
45
+ };
46
+
47
+ const stopEvent = (event: ReactMouseEvent) => {
48
+ event.preventDefault();
49
+ event.stopPropagation();
50
+ };
51
+
52
+ const onCancel = () => {
53
+ setVote(null);
54
+ setSelectedReason('');
55
+ setDetails('');
56
+ setEmail('');
57
+ setIsSubmitting(false);
58
+ setSubmitError(false);
59
+ };
60
+
61
+ const onSubmit = async () => {
62
+ if (!vote || !selectedReason.trim() || isSubmitting) return;
63
+
64
+ setIsSubmitting(true);
65
+ setSubmitError(false);
66
+
67
+ const result = await submitPublicFeedback({
68
+ helpful: vote === 'yes',
69
+ reasonText: selectedReason,
70
+ details: showOptionalInputs ? details : undefined,
71
+ email: showOptionalInputs ? email : undefined,
72
+ pageUrl: window.location.href,
73
+ siteHost: window.location.host,
74
+ });
75
+
76
+ if (result.ok) {
77
+ onCancel();
78
+ return;
79
+ }
80
+
81
+ setSubmitError(true);
82
+ setIsSubmitting(false);
83
+ };
84
+
85
+ return (
86
+ <div className="velu-page-feedback-block">
87
+ <div className="velu-page-feedback-row">
88
+ <p className="velu-page-feedback-question">Was this page helpful?</p>
89
+ <div className="velu-page-feedback-actions" role="group" aria-label="Feedback options">
90
+ <button
91
+ type="button"
92
+ className={['velu-page-feedback-btn', vote === 'yes' ? 'is-active' : ''].filter(Boolean).join(' ')}
93
+ aria-label="Mark page as helpful"
94
+ disabled={isSubmitting}
95
+ onClick={(event) => {
96
+ stopEvent(event);
97
+ onChooseVote('yes');
98
+ }}
99
+ >
100
+ <ThumbsUp />
101
+ <span>Yes</span>
102
+ </button>
103
+ <button
104
+ type="button"
105
+ className={['velu-page-feedback-btn', vote === 'no' ? 'is-active' : ''].filter(Boolean).join(' ')}
106
+ aria-label="Mark page as not helpful"
107
+ disabled={isSubmitting}
108
+ onClick={(event) => {
109
+ stopEvent(event);
110
+ onChooseVote('no');
111
+ }}
112
+ >
113
+ <ThumbsDown />
114
+ <span>No</span>
115
+ </button>
116
+ </div>
117
+ </div>
118
+
119
+ {showForm ? (
120
+ <div className="velu-page-feedback-panel">
121
+ <h3 className="velu-page-feedback-panel-title">
122
+ {vote === 'yes' ? 'Great! What worked best for you?' : 'How can we improve our product?'}
123
+ </h3>
124
+
125
+ <div className="velu-page-feedback-options" role="radiogroup" aria-label="Feedback reasons">
126
+ {options.map((option) => {
127
+ const checked = selectedReason === option;
128
+ return (
129
+ <button
130
+ key={option}
131
+ type="button"
132
+ role="radio"
133
+ aria-checked={checked}
134
+ disabled={isSubmitting}
135
+ className={['velu-page-feedback-option', checked ? 'is-checked' : ''].filter(Boolean).join(' ')}
136
+ onClick={(event) => {
137
+ stopEvent(event);
138
+ setSelectedReason(option);
139
+ setSubmitError(false);
140
+ }}
141
+ >
142
+ <span className="velu-page-feedback-radio" aria-hidden="true" />
143
+ <span>{option}</span>
144
+ </button>
145
+ );
146
+ })}
147
+ </div>
148
+
149
+ {showOptionalInputs ? (
150
+ <div className="velu-page-feedback-inputs">
151
+ <textarea
152
+ className="velu-page-feedback-input"
153
+ rows={3}
154
+ placeholder="(Optional) Could you share more about your experience?"
155
+ value={details}
156
+ disabled={isSubmitting}
157
+ onChange={(event) => setDetails(event.target.value)}
158
+ />
159
+ <input
160
+ className="velu-page-feedback-input"
161
+ type="email"
162
+ placeholder="(Optional) Email"
163
+ value={email}
164
+ disabled={isSubmitting}
165
+ onChange={(event) => setEmail(event.target.value)}
166
+ />
167
+ </div>
168
+ ) : null}
169
+
170
+ <div className="velu-page-feedback-cta">
171
+ <button
172
+ type="button"
173
+ className="velu-page-feedback-cancel"
174
+ disabled={isSubmitting}
175
+ onClick={(event) => {
176
+ stopEvent(event);
177
+ onCancel();
178
+ }}
179
+ >
180
+ Cancel
181
+ </button>
182
+ <button
183
+ type="button"
184
+ className="velu-page-feedback-submit"
185
+ disabled={!canSubmit}
186
+ aria-busy={isSubmitting}
187
+ title={submitError ? 'Unable to submit feedback right now. Please try again.' : undefined}
188
+ onClick={(event) => {
189
+ stopEvent(event);
190
+ void onSubmit();
191
+ }}
192
+ >
193
+ Submit feedback
194
+ </button>
195
+ </div>
196
+ </div>
197
+ ) : null}
198
+ </div>
199
+ );
200
+ }
@@ -0,0 +1,107 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
+ import { usePathname } from 'next/navigation';
5
+ import type { VeluProductOption, VeluIconLibrary } from '@core/types';
6
+ import { VeluIcon } from './icon';
7
+
8
+ function ChevronDownIcon() {
9
+ return (
10
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
11
+ <path d="m6 9 6 6 6-6" />
12
+ </svg>
13
+ );
14
+ }
15
+
16
+ function withTrailingSlashPath(path: string): string {
17
+ if (!path.startsWith('/')) return path;
18
+ if (path === '/' || path.endsWith('/')) return path;
19
+ return `${path}/`;
20
+ }
21
+
22
+ export function ProductSwitcher({
23
+ products,
24
+ iconLibrary,
25
+ }: {
26
+ products: VeluProductOption[];
27
+ iconLibrary: VeluIconLibrary;
28
+ }) {
29
+ const pathname = usePathname();
30
+ const [open, setOpen] = useState(false);
31
+ const ref = useRef<HTMLDivElement>(null);
32
+
33
+ const current = useMemo(() => {
34
+ const firstSeg = pathname.split('/').filter(Boolean)[0] ?? '';
35
+ return products.find((p) => p.slug === firstSeg) ?? products[0];
36
+ }, [pathname, products]);
37
+
38
+ useEffect(() => {
39
+ function handleClick(e: MouseEvent) {
40
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
41
+ }
42
+ document.addEventListener('mousedown', handleClick);
43
+ return () => document.removeEventListener('mousedown', handleClick);
44
+ }, []);
45
+
46
+ if (!current || products.length <= 1) return null;
47
+
48
+ function switchTo(target: VeluProductOption) {
49
+ setOpen(false);
50
+
51
+ const segments = pathname.split('/').filter(Boolean);
52
+ const firstSeg = segments[0] ?? '';
53
+
54
+ if (current && current.slug === firstSeg) {
55
+ // Replace the product segment, keep tab/group/page segments
56
+ const rest = segments.slice(1);
57
+ if (rest.length > 0) {
58
+ window.location.href = withTrailingSlashPath('/' + [target.slug, ...rest].join('/'));
59
+ return;
60
+ }
61
+ }
62
+
63
+ window.location.href = withTrailingSlashPath(target.defaultPath);
64
+ }
65
+
66
+ return (
67
+ <div className="velu-product-switcher-wrap" ref={ref}>
68
+ <button type="button" className="velu-product-switcher" onClick={() => setOpen((v) => !v)}>
69
+ <span className="velu-product-switcher-label-wrap">
70
+ <VeluIcon
71
+ name={current.icon}
72
+ iconType={current.iconType}
73
+ library={iconLibrary}
74
+ className="velu-product-icon"
75
+ />
76
+ <span className="velu-product-switcher-label">{current.product}</span>
77
+ </span>
78
+ <ChevronDownIcon />
79
+ </button>
80
+ {open && (
81
+ <div className="velu-product-menu">
82
+ {products.map((product) => (
83
+ <button
84
+ key={product.slug}
85
+ type="button"
86
+ className={`velu-product-option ${product.slug === current.slug ? 'active' : ''}`}
87
+ onClick={() => switchTo(product)}
88
+ >
89
+ <span className="velu-product-option-name-wrap">
90
+ <VeluIcon
91
+ name={product.icon}
92
+ iconType={product.iconType}
93
+ library={iconLibrary}
94
+ className="velu-product-option-icon"
95
+ />
96
+ <span className="velu-product-option-name">{product.product}</span>
97
+ </span>
98
+ {product.description && (
99
+ <span className="velu-product-option-desc">{product.description}</span>
100
+ )}
101
+ </button>
102
+ ))}
103
+ </div>
104
+ )}
105
+ </div>
106
+ );
107
+ }
@@ -0,0 +1,90 @@
1
+ "use client";
2
+
3
+ import { isValidElement, useMemo, useState, type ReactNode } from 'react';
4
+ import { VeluIcon } from './icon';
5
+
6
+ function flattenText(node: ReactNode): string {
7
+ if (node == null || typeof node === 'boolean') return '';
8
+ if (typeof node === 'string' || typeof node === 'number') return String(node);
9
+ if (Array.isArray(node)) return node.map((item) => flattenText(item)).join('');
10
+ if (isValidElement(node)) return flattenText((node.props as { children?: ReactNode })?.children);
11
+ return '';
12
+ }
13
+
14
+ function renderInlineMarkdown(text: string): ReactNode[] {
15
+ const tokens = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*)/g).filter(Boolean);
16
+ return tokens.map((token, index) => {
17
+ if (token.startsWith('**') && token.endsWith('**')) {
18
+ return <strong key={`b-${index}`}>{token.slice(2, -2)}</strong>;
19
+ }
20
+ if (token.startsWith('*') && token.endsWith('*')) {
21
+ return <em key={`i-${index}`}>{token.slice(1, -1)}</em>;
22
+ }
23
+ return <span key={`t-${index}`}>{token}</span>;
24
+ });
25
+ }
26
+
27
+ export function VeluPrompt({
28
+ description,
29
+ children,
30
+ icon,
31
+ iconType,
32
+ actions,
33
+ className,
34
+ }: {
35
+ description?: string;
36
+ children?: ReactNode;
37
+ icon?: string;
38
+ iconType?: string;
39
+ actions?: string[];
40
+ className?: string;
41
+ }) {
42
+ const [copied, setCopied] = useState(false);
43
+ const promptText = useMemo(() => flattenText(children).trim(), [children]);
44
+ const label = (description && description.trim()) || 'Prompt';
45
+ const actionSet = new Set((Array.isArray(actions) && actions.length > 0 ? actions : ['copy']).map((item) => String(item).toLowerCase()));
46
+ const showCopy = actionSet.has('copy');
47
+ const showCursor = actionSet.has('cursor');
48
+
49
+ const onCopy = async () => {
50
+ if (!promptText) return;
51
+ try {
52
+ await navigator.clipboard.writeText(promptText);
53
+ setCopied(true);
54
+ window.setTimeout(() => setCopied(false), 1200);
55
+ } catch {
56
+ // no-op
57
+ }
58
+ };
59
+
60
+ const onCursor = () => {
61
+ if (!promptText) return;
62
+ const url = `https://cursor.com/link/prompt?text=${encodeURIComponent(promptText)}`;
63
+ window.open(url, '_blank', 'noopener,noreferrer');
64
+ };
65
+
66
+ return (
67
+ <div className={['velu-prompt', className].filter(Boolean).join(' ')}>
68
+ <div className="velu-prompt-row">
69
+ <div className="velu-prompt-left">
70
+ {icon ? <VeluIcon name={icon} iconType={iconType} className="velu-prompt-icon" /> : null}
71
+ <div className="velu-prompt-desc">{renderInlineMarkdown(label)}</div>
72
+ </div>
73
+ <div className="velu-prompt-actions">
74
+ {showCopy ? (
75
+ <button type="button" className="velu-prompt-copy" onClick={onCopy}>
76
+ {copied ? 'Copied' : 'Copy prompt'}
77
+ </button>
78
+ ) : null}
79
+ {showCursor ? (
80
+ <button type="button" className="velu-prompt-open" onClick={onCursor}>
81
+ <img src="/icons/cursor-dark.svg" alt="" aria-hidden="true" className="velu-prompt-open-icon velu-prompt-open-icon-on-light" />
82
+ <img src="/icons/cursor-light.svg" alt="" aria-hidden="true" className="velu-prompt-open-icon velu-prompt-open-icon-on-dark" />
83
+ Open in Cursor
84
+ </button>
85
+ ) : null}
86
+ </div>
87
+ </div>
88
+ </div>
89
+ );
90
+ }
@@ -0,0 +1,21 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { RootProvider } from 'fumadocs-ui/provider/next';
5
+ import { PagefindSearch } from './search';
6
+
7
+ interface ProvidersProps {
8
+ children: ReactNode;
9
+ theme?: {
10
+ defaultTheme: string;
11
+ enableSystem: boolean;
12
+ };
13
+ }
14
+
15
+ export function Providers({ children, theme }: ProvidersProps) {
16
+ return (
17
+ <RootProvider theme={theme} search={{ SearchDialog: PagefindSearch }}>
18
+ {children}
19
+ </RootProvider>
20
+ );
21
+ }