@aravindc26/velu 0.11.0 → 0.11.3

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 (60) hide show
  1. package/package.json +15 -6
  2. package/schema/velu.schema.json +1251 -115
  3. package/src/build.ts +1121 -304
  4. package/src/cli.ts +90 -26
  5. package/src/engine/_server.mjs +1684 -277
  6. package/src/engine/app/(docs)/[...slug]/layout.tsx +371 -0
  7. package/src/engine/app/(docs)/[...slug]/page.tsx +926 -0
  8. package/src/engine/app/api/proxy/route.ts +23 -0
  9. package/src/engine/app/copy-page.css +59 -1
  10. package/src/engine/app/global.css +3157 -3
  11. package/src/engine/app/layout.tsx +56 -1
  12. package/src/engine/app/llms-file/route.ts +87 -0
  13. package/src/engine/app/llms-full-file/route.ts +62 -0
  14. package/src/engine/app/md-file/[...slug]/route.ts +409 -0
  15. package/src/engine/app/page.tsx +45 -0
  16. package/src/engine/app/robots.txt/route.ts +63 -0
  17. package/src/engine/app/rss-file/[...slug]/route.ts +169 -0
  18. package/src/engine/app/sitemap.xml/route.ts +82 -0
  19. package/src/engine/components/assistant.tsx +16 -5
  20. package/src/engine/components/changelog-filters.tsx +114 -0
  21. package/src/engine/components/code-group.tsx +383 -0
  22. package/src/engine/components/color.tsx +118 -0
  23. package/src/engine/components/expandable.tsx +77 -0
  24. package/src/engine/components/icon.tsx +136 -0
  25. package/src/engine/components/image-zoom-fallback.tsx +147 -0
  26. package/src/engine/components/image.tsx +111 -0
  27. package/src/engine/components/manual-api-playground.tsx +154 -0
  28. package/src/engine/components/mermaid.tsx +142 -0
  29. package/src/engine/components/openapi-toc-sync.tsx +59 -0
  30. package/src/engine/components/openapi.tsx +1682 -0
  31. package/src/engine/components/page-feedback.tsx +153 -0
  32. package/src/engine/components/product-switcher.tsx +27 -3
  33. package/src/engine/components/prompt.tsx +90 -0
  34. package/src/engine/components/providers.tsx +1 -6
  35. package/src/engine/components/search.tsx +4 -0
  36. package/src/engine/components/sidebar-links.tsx +13 -15
  37. package/src/engine/components/synced-tabs.tsx +57 -0
  38. package/src/engine/components/toc-examples.tsx +110 -0
  39. package/src/engine/components/view.tsx +344 -0
  40. package/src/engine/generated/redirects.ts +3 -0
  41. package/src/engine/lib/changelog.ts +246 -0
  42. package/src/engine/lib/layout.shared.ts +30 -2
  43. package/src/engine/lib/llms.ts +444 -0
  44. package/src/engine/lib/navigation-normalize.mjs +481 -412
  45. package/src/engine/lib/navigation-normalize.ts +261 -54
  46. package/src/engine/lib/redirects.ts +194 -0
  47. package/src/engine/lib/source.ts +107 -4
  48. package/src/engine/lib/velu.ts +368 -2
  49. package/src/engine/mdx-components.tsx +648 -0
  50. package/src/engine/middleware.ts +66 -0
  51. package/src/engine/public/icons/cursor-dark.svg +12 -0
  52. package/src/engine/public/icons/cursor-light.svg +12 -0
  53. package/src/engine/source.config.ts +98 -1
  54. package/src/engine/src/components/PageTitle.astro +16 -5
  55. package/src/engine/src/lib/velu.ts +11 -3
  56. package/src/navigation-normalize.ts +252 -54
  57. package/src/themes.ts +6 -6
  58. package/src/validate.ts +119 -6
  59. package/src/engine/app/(docs)/[[...slug]]/layout.tsx +0 -87
  60. package/src/engine/app/(docs)/[[...slug]]/page.tsx +0 -146
@@ -0,0 +1,383 @@
1
+ "use client";
2
+
3
+ import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
4
+
5
+ const VELU_TAB_SYNC_EVENT = 'velu:tab-sync';
6
+ const VELU_TAB_SYNC_KEY = '__veluTabSyncLabel';
7
+
8
+ function normalizeSyncLabel(value: string): string {
9
+ return value.trim().toLowerCase();
10
+ }
11
+
12
+ function readSharedSyncLabel(): string | null {
13
+ if (typeof window === 'undefined') return null;
14
+ const value = (window as any)[VELU_TAB_SYNC_KEY];
15
+ return typeof value === 'string' && value.trim() ? value : null;
16
+ }
17
+
18
+ function broadcastSyncLabel(label: string) {
19
+ if (typeof window === 'undefined') return;
20
+ const existing = readSharedSyncLabel();
21
+ if (existing && normalizeSyncLabel(existing) === normalizeSyncLabel(label)) return;
22
+ (window as any)[VELU_TAB_SYNC_KEY] = label;
23
+ window.dispatchEvent(new CustomEvent(VELU_TAB_SYNC_EVENT, { detail: { label } }));
24
+ }
25
+
26
+ function findNestedProp(node: any, key: string): string | undefined {
27
+ if (!node) return undefined;
28
+ if (Array.isArray(node)) {
29
+ for (const item of node) {
30
+ const found = findNestedProp(item, key);
31
+ if (found) return found;
32
+ }
33
+ return undefined;
34
+ }
35
+ if (!isValidElement(node)) return undefined;
36
+ const props = (node as ReactElement<Record<string, unknown>>).props;
37
+ const direct = props?.[key];
38
+ if (typeof direct === 'string' && direct.trim()) return direct.trim();
39
+ return findNestedProp(props?.children, key);
40
+ }
41
+
42
+ function findNestedClassName(node: any): string | undefined {
43
+ if (!node) return undefined;
44
+ if (Array.isArray(node)) {
45
+ for (const item of node) {
46
+ const found = findNestedClassName(item);
47
+ if (found) return found;
48
+ }
49
+ return undefined;
50
+ }
51
+ if (!isValidElement(node)) return undefined;
52
+ const props = (node as ReactElement<Record<string, unknown>>).props;
53
+ if (typeof props?.className === 'string' && props.className) {
54
+ return props.className;
55
+ }
56
+ return findNestedClassName(props?.children);
57
+ }
58
+
59
+ function stripTitleProps(node: ReactNode): ReactNode {
60
+ if (Array.isArray(node)) return node.map((item) => stripTitleProps(item));
61
+ if (!isValidElement(node)) return node;
62
+ const props = (node as ReactElement<Record<string, unknown>>).props;
63
+
64
+ const nextProps: Record<string, unknown> = {};
65
+ if (props?.children !== undefined) {
66
+ nextProps.children = stripTitleProps(props.children as ReactNode);
67
+ }
68
+ if ('title' in (props ?? {})) {
69
+ nextProps.title = undefined;
70
+ }
71
+ if ('data-title' in (props ?? {})) {
72
+ nextProps['data-title'] = undefined;
73
+ }
74
+
75
+ return cloneElement(node as ReactElement, nextProps);
76
+ }
77
+
78
+ function languageFromClassName(className: string | undefined): string | undefined {
79
+ const langMatch = typeof className === 'string' ? className.match(/language-([a-z0-9_-]+)/i) : null;
80
+ if (!langMatch) return undefined;
81
+ return langMatch[1].toLowerCase();
82
+ }
83
+
84
+ function languageName(language: string | undefined): string | undefined {
85
+ if (!language) return undefined;
86
+ const map: Record<string, string> = {
87
+ js: 'JavaScript',
88
+ javascript: 'JavaScript',
89
+ jsx: 'JavaScript',
90
+ ts: 'TypeScript',
91
+ typescript: 'TypeScript',
92
+ tsx: 'TypeScript',
93
+ py: 'Python',
94
+ python: 'Python',
95
+ java: 'Java',
96
+ rb: 'Ruby',
97
+ sh: 'Shell',
98
+ bash: 'Bash',
99
+ zsh: 'Zsh',
100
+ yml: 'YAML',
101
+ md: 'Markdown',
102
+ mdx: 'MDX',
103
+ };
104
+ return map[language] ?? language.charAt(0).toUpperCase() + language.slice(1);
105
+ }
106
+
107
+ function languageFromLabel(label: string): string | undefined {
108
+ const map: Record<string, string> = {
109
+ js: 'javascript',
110
+ jsx: 'javascript',
111
+ javascript: 'javascript',
112
+ node: 'javascript',
113
+ ts: 'typescript',
114
+ tsx: 'typescript',
115
+ typescript: 'typescript',
116
+ py: 'python',
117
+ python: 'python',
118
+ java: 'java',
119
+ ruby: 'ruby',
120
+ rb: 'ruby',
121
+ shell: 'shell',
122
+ bash: 'shell',
123
+ sh: 'shell',
124
+ yml: 'yaml',
125
+ yaml: 'yaml',
126
+ mdx: 'markdown',
127
+ md: 'markdown',
128
+ };
129
+ const trimmed = label.trim().toLowerCase();
130
+ if (!trimmed) return undefined;
131
+
132
+ if (map[trimmed]) return map[trimmed];
133
+
134
+ const baseNoExt = trimmed.replace(/\.[a-z0-9_+-]+$/, '');
135
+ if (map[baseNoExt]) return map[baseNoExt];
136
+
137
+ const ext = trimmed.match(/\.([a-z0-9_+-]+)$/)?.[1];
138
+ if (ext && map[ext]) return map[ext];
139
+
140
+ return undefined;
141
+ }
142
+
143
+ function languageAbbr(language: string | undefined): string {
144
+ if (!language) return 'TXT';
145
+ const map: Record<string, string> = {
146
+ javascript: 'JS',
147
+ typescript: 'TS',
148
+ python: 'PY',
149
+ java: 'JV',
150
+ ruby: 'RB',
151
+ shell: 'SH',
152
+ bash: 'SH',
153
+ yaml: 'YM',
154
+ markdown: 'MD',
155
+ md: 'MD',
156
+ mdx: 'MDX',
157
+ };
158
+ return map[language] ?? language.slice(0, 2).toUpperCase();
159
+ }
160
+
161
+ const LANGUAGE_ICON_URL: Record<string, string> = {
162
+ javascript: 'https://cdn.jsdelivr.net/gh/devicons/devicon/icons/javascript/javascript-original.svg',
163
+ typescript: 'https://cdn.jsdelivr.net/gh/devicons/devicon/icons/typescript/typescript-original.svg',
164
+ python: 'https://cdn.jsdelivr.net/gh/devicons/devicon/icons/python/python-original.svg',
165
+ java: 'https://cdn.jsdelivr.net/gh/devicons/devicon/icons/java/java-original.svg',
166
+ ruby: 'https://cdn.jsdelivr.net/gh/devicons/devicon/icons/ruby/ruby-original.svg',
167
+ shell: 'https://cdn.jsdelivr.net/gh/devicons/devicon/icons/bash/bash-original.svg',
168
+ bash: 'https://cdn.jsdelivr.net/gh/devicons/devicon/icons/bash/bash-original.svg',
169
+ yaml: 'https://cdn.jsdelivr.net/gh/devicons/devicon/icons/yaml/yaml-original.svg',
170
+ markdown: 'https://cdn.jsdelivr.net/gh/devicons/devicon/icons/markdown/markdown-original.svg',
171
+ };
172
+
173
+ function normalizeLanguage(language: string | undefined): string {
174
+ if (!language) return 'text';
175
+ const lower = language.toLowerCase();
176
+ const map: Record<string, string> = {
177
+ js: 'javascript',
178
+ jsx: 'javascript',
179
+ ts: 'typescript',
180
+ tsx: 'typescript',
181
+ py: 'python',
182
+ sh: 'shell',
183
+ yml: 'yaml',
184
+ md: 'markdown',
185
+ mdx: 'mdx',
186
+ };
187
+ return map[lower] ?? lower;
188
+ }
189
+
190
+ function LanguageIcon({ language, label, abbr }: { language: string; label: string; abbr: string }) {
191
+ if (language === 'text') {
192
+ return null;
193
+ }
194
+
195
+ if (language === 'markdown' || language === 'mdx' || language === 'md') {
196
+ return <span className={['velu-lang-icon', `velu-lang-${language}`].join(' ')}>{abbr}</span>;
197
+ }
198
+
199
+ const src = LANGUAGE_ICON_URL[language];
200
+ if (src) {
201
+ return <img src={src} alt={`${label} icon`} className="velu-lang-icon-img" loading="lazy" decoding="async" />;
202
+ }
203
+
204
+ return null;
205
+ }
206
+
207
+ function getCodeLabel(block: any, index: number): string {
208
+ const title = block?.props?.title
209
+ || block?.props?.['data-title']
210
+ || findNestedProp(block, 'title')
211
+ || findNestedProp(block, 'data-title');
212
+ if (typeof title === 'string' && title.trim()) return title.trim();
213
+
214
+ const cls = findNestedClassName(block) || block?.props?.className || '';
215
+ const language = languageFromClassName(cls);
216
+ if (language) return languageName(normalizeLanguage(language)) ?? language;
217
+
218
+ return `Code ${index + 1}`;
219
+ }
220
+
221
+ function getCodeLanguage(block: any): string | undefined {
222
+ const cls = findNestedClassName(block) || block?.props?.className || '';
223
+ return languageFromClassName(cls);
224
+ }
225
+
226
+ export function VeluCodeGroup({ children, className, dropdown, items, labels: labelItems }: any) {
227
+ const blocks = useMemo(
228
+ () => Children.toArray(children).filter((child) => isValidElement(child) && child.type !== 'br') as any[],
229
+ [children],
230
+ );
231
+ const explicitLabels = useMemo(() => {
232
+ const source = Array.isArray(items) ? items : Array.isArray(labelItems) ? labelItems : [];
233
+ return source.map((value) => String(value));
234
+ }, [items, labelItems]);
235
+
236
+ const blockLabels = useMemo(
237
+ () => blocks.map((block, index) => explicitLabels[index] ?? getCodeLabel(block, index)),
238
+ [blocks, explicitLabels],
239
+ );
240
+ const cleanedBlocks = useMemo(() => blocks.map((block) => stripTitleProps(block)), [blocks]);
241
+ const codeMeta = useMemo(
242
+ () => blocks.map((block, index) => {
243
+ const label = explicitLabels[index] ?? getCodeLabel(block, index);
244
+ const resolvedLanguage = getCodeLanguage(block) ?? languageFromLabel(label);
245
+ const language = normalizeLanguage(resolvedLanguage);
246
+ return {
247
+ label,
248
+ language,
249
+ languageLabel: resolvedLanguage ? (languageName(language) ?? label) : label,
250
+ abbr: languageAbbr(language),
251
+ };
252
+ }),
253
+ [blocks, explicitLabels],
254
+ );
255
+
256
+ const [activeIndex, setActiveIndex] = useState(0);
257
+ const [menuOpen, setMenuOpen] = useState(false);
258
+ const menuRef = useRef<HTMLDivElement | null>(null);
259
+ const clampedIndex = Math.min(activeIndex, Math.max(0, cleanedBlocks.length - 1));
260
+
261
+ useEffect(() => {
262
+ if (!menuOpen) return;
263
+ const onPointerDown = (event: MouseEvent) => {
264
+ if (!menuRef.current) return;
265
+ if (menuRef.current.contains(event.target as Node)) return;
266
+ setMenuOpen(false);
267
+ };
268
+ document.addEventListener('mousedown', onPointerDown);
269
+ return () => document.removeEventListener('mousedown', onPointerDown);
270
+ }, [menuOpen]);
271
+
272
+ useEffect(() => {
273
+ const syncLabels = codeMeta.map((item) => item.languageLabel);
274
+ if (syncLabels.length === 0) return;
275
+
276
+ const applyLabel = (label: string) => {
277
+ const target = normalizeSyncLabel(label);
278
+ const idx = syncLabels.findIndex((item) => normalizeSyncLabel(item) === target);
279
+ if (idx >= 0) setActiveIndex((prev) => (prev === idx ? prev : idx));
280
+ };
281
+
282
+ const existing = readSharedSyncLabel();
283
+ if (existing) applyLabel(existing);
284
+
285
+ const onSync = (event: Event) => {
286
+ const detail = (event as CustomEvent<{ label?: string }>).detail;
287
+ if (!detail?.label) return;
288
+ applyLabel(detail.label);
289
+ };
290
+
291
+ window.addEventListener(VELU_TAB_SYNC_EVENT, onSync);
292
+ return () => window.removeEventListener(VELU_TAB_SYNC_EVENT, onSync);
293
+ }, [codeMeta]);
294
+
295
+ if (blocks.length <= 1) {
296
+ return <div className={['velu-code-group', className].filter(Boolean).join(' ')}>{children}</div>;
297
+ }
298
+
299
+ if (!dropdown) {
300
+ return (
301
+ <div className={['velu-code-group', 'velu-code-group-tabs', className].filter(Boolean).join(' ')}>
302
+ <div className="velu-code-group-tabs-head" role="tablist" aria-label="Code variants">
303
+ {codeMeta.map((item, index) => (
304
+ <button
305
+ key={item.label + index}
306
+ type="button"
307
+ role="tab"
308
+ aria-selected={clampedIndex === index}
309
+ className={['velu-code-group-tab-btn', clampedIndex === index ? 'is-active' : ''].filter(Boolean).join(' ')}
310
+ onClick={() => {
311
+ setActiveIndex((prev) => (prev === index ? prev : index));
312
+ if (item.languageLabel) broadcastSyncLabel(item.languageLabel);
313
+ }}
314
+ >
315
+ <LanguageIcon language={item.language} label={item.languageLabel} abbr={item.abbr} />
316
+ <span>{item.label}</span>
317
+ </button>
318
+ ))}
319
+ </div>
320
+ <div className="velu-code-group-dropdown-body">{cleanedBlocks[clampedIndex]}</div>
321
+ </div>
322
+ );
323
+ }
324
+
325
+ if (dropdown) {
326
+ return (
327
+ <div className={['velu-code-group', 'velu-code-group-dropdown', className].filter(Boolean).join(' ')}>
328
+ <div className="velu-code-group-dropdown-head">
329
+ <span className="velu-code-group-file">
330
+ <LanguageIcon
331
+ language={codeMeta[clampedIndex]?.language ?? 'text'}
332
+ label={codeMeta[clampedIndex]?.languageLabel ?? 'Text'}
333
+ abbr={codeMeta[clampedIndex]?.abbr ?? 'TX'}
334
+ />
335
+ <span>{codeMeta[clampedIndex]?.label ?? blockLabels[clampedIndex]}</span>
336
+ </span>
337
+ <div className="velu-code-group-select-wrap" ref={menuRef}>
338
+ <button
339
+ type="button"
340
+ className="velu-code-group-select-btn"
341
+ onClick={() => setMenuOpen((open) => !open)}
342
+ aria-expanded={menuOpen}
343
+ aria-haspopup="listbox"
344
+ >
345
+ <LanguageIcon
346
+ language={codeMeta[clampedIndex]?.language ?? 'text'}
347
+ label={codeMeta[clampedIndex]?.languageLabel ?? 'Text'}
348
+ abbr={codeMeta[clampedIndex]?.abbr ?? 'TX'}
349
+ />
350
+ <span>{codeMeta[clampedIndex]?.languageLabel}</span>
351
+ <span className="velu-code-group-caret">⌄</span>
352
+ </button>
353
+ {menuOpen ? (
354
+ <div className="velu-code-group-select-menu" role="listbox" aria-label="Select language">
355
+ {codeMeta.map((item, index) => (
356
+ <button
357
+ key={item.label + index}
358
+ type="button"
359
+ role="option"
360
+ aria-selected={clampedIndex === index}
361
+ className={['velu-code-group-select-item', clampedIndex === index ? 'is-active' : ''].filter(Boolean).join(' ')}
362
+ onClick={() => {
363
+ setActiveIndex((prev) => (prev === index ? prev : index));
364
+ if (item.languageLabel) broadcastSyncLabel(item.languageLabel);
365
+ setMenuOpen(false);
366
+ }}
367
+ >
368
+ <LanguageIcon language={item.language} label={item.languageLabel} abbr={item.abbr} />
369
+ <span>{item.languageLabel}</span>
370
+ {clampedIndex === index ? <span className="velu-code-group-check">✓</span> : null}
371
+ </button>
372
+ ))}
373
+ </div>
374
+ ) : null}
375
+ </div>
376
+ </div>
377
+ <div className="velu-code-group-dropdown-body">
378
+ {cleanedBlocks[clampedIndex]}
379
+ </div>
380
+ </div>
381
+ );
382
+ }
383
+ }
@@ -0,0 +1,118 @@
1
+ "use client";
2
+
3
+ import { type CSSProperties, type ReactNode, useState } from 'react';
4
+
5
+ type ThemeValue = { light?: string; dark?: string };
6
+ type ColorValue = string | ThemeValue;
7
+
8
+ function valueToCopy(value: ColorValue | undefined): string {
9
+ if (!value) return '';
10
+ if (typeof value === 'string') return value;
11
+ const light = value.light ?? '';
12
+ const dark = value.dark ?? '';
13
+ if (light && dark) return `${light} / ${dark}`;
14
+ return light || dark;
15
+ }
16
+
17
+ function swatchStyle(value: ColorValue | undefined): CSSProperties {
18
+ if (!value) return { backgroundColor: '#16A34A' };
19
+ if (typeof value === 'string') return { backgroundColor: value };
20
+ const light = value.light ?? value.dark ?? '#16A34A';
21
+ const dark = value.dark ?? value.light ?? '#16A34A';
22
+ return {
23
+ ['--velu-color-light' as any]: light,
24
+ ['--velu-color-dark' as any]: dark,
25
+ };
26
+ }
27
+
28
+ function valueLabel(value: ColorValue | undefined): string {
29
+ if (!value) return '';
30
+ if (typeof value === 'string') return value;
31
+ const parts = [
32
+ value.light ? `light: ${value.light}` : null,
33
+ value.dark ? `dark: ${value.dark}` : null,
34
+ ].filter(Boolean);
35
+ return parts.join(' / ');
36
+ }
37
+
38
+ export function VeluColorItem({ name, value, className }: { name?: string; value?: ColorValue; className?: string }) {
39
+ const [copied, setCopied] = useState(false);
40
+ const copyText = valueToCopy(value);
41
+
42
+ return (
43
+ <button
44
+ type="button"
45
+ className={['velu-color-item', className].filter(Boolean).join(' ')}
46
+ title={copied ? 'Copied' : 'Click to copy'}
47
+ aria-label={`Copy ${name ?? 'color'} value`}
48
+ onClick={async () => {
49
+ if (!copyText) return;
50
+ try {
51
+ await navigator.clipboard.writeText(copyText);
52
+ setCopied(true);
53
+ window.setTimeout(() => setCopied(false), 1200);
54
+ } catch {
55
+ // Ignore clipboard errors in non-secure contexts.
56
+ }
57
+ }}
58
+ data-copied={copied ? 'true' : undefined}
59
+ >
60
+ <span className="velu-color-swatch-wrap">
61
+ <span className="velu-color-swatch" style={swatchStyle(value)} />
62
+ <span className={['velu-color-copied-check', copied ? 'is-visible' : ''].filter(Boolean).join(' ')}>✓</span>
63
+ </span>
64
+ <div className="velu-color-item-text">
65
+ {name ? <code>{name}</code> : null}
66
+ {value ? <span>{valueLabel(value)}</span> : null}
67
+ </div>
68
+ </button>
69
+ );
70
+ }
71
+
72
+ export function VeluColorRow({ title, children, className }: { title?: string; children?: ReactNode; className?: string }) {
73
+ return (
74
+ <div className={['velu-color-row', className].filter(Boolean).join(' ')}>
75
+ {title ? <div className="velu-color-row-title">{title}</div> : null}
76
+ <div className="velu-color-row-items">{children}</div>
77
+ </div>
78
+ );
79
+ }
80
+
81
+ export function VeluColor({
82
+ children,
83
+ className,
84
+ variant = 'compact',
85
+ color,
86
+ hex,
87
+ name,
88
+ }: {
89
+ children?: ReactNode;
90
+ className?: string;
91
+ variant?: 'compact' | 'table' | string;
92
+ color?: string;
93
+ hex?: string;
94
+ name?: string;
95
+ }) {
96
+ if (children == null && (color || hex || name)) {
97
+ const value = color ?? hex ?? '#16A34A';
98
+ return (
99
+ <div className={['velu-color', className].filter(Boolean).join(' ')}>
100
+ <span className="velu-color-swatch" style={{ backgroundColor: value }} />
101
+ <code>{name ?? value}</code>
102
+ </div>
103
+ );
104
+ }
105
+
106
+ return (
107
+ <div
108
+ className={[
109
+ 'velu-color',
110
+ 'velu-color-group',
111
+ variant === 'table' ? 'velu-color-table' : 'velu-color-compact',
112
+ className,
113
+ ].filter(Boolean).join(' ')}
114
+ >
115
+ {children}
116
+ </div>
117
+ );
118
+ }
@@ -0,0 +1,77 @@
1
+ "use client";
2
+
3
+ import { type ReactNode, useMemo, useState } from 'react';
4
+
5
+ function normalizeBool(value: unknown): boolean {
6
+ if (typeof value === 'boolean') return value;
7
+ if (typeof value === 'string') {
8
+ const trimmed = value.trim().toLowerCase();
9
+ return trimmed === '' || trimmed === 'true' || trimmed === '1' || trimmed === 'yes';
10
+ }
11
+ if (typeof value === 'number') return value === 1;
12
+ return false;
13
+ }
14
+
15
+ function lcFirst(input: string): string {
16
+ if (!input) return input;
17
+ return input.charAt(0).toLowerCase() + input.slice(1);
18
+ }
19
+
20
+ function deriveClosedLabel(baseLabel: string): string {
21
+ return `Show ${lcFirst(baseLabel)}`;
22
+ }
23
+
24
+ function deriveOpenLabel(closedLabel: string): string {
25
+ if (/^show\s+/i.test(closedLabel)) {
26
+ return closedLabel.replace(/^show/i, 'Hide');
27
+ }
28
+ return `Hide ${closedLabel}`;
29
+ }
30
+
31
+ export function VeluExpandable({
32
+ title,
33
+ summary,
34
+ defaultOpen,
35
+ defaultopen,
36
+ openTitle,
37
+ opentitle,
38
+ closeTitle,
39
+ closetitle,
40
+ children,
41
+ className,
42
+ }: {
43
+ title?: string;
44
+ summary?: string;
45
+ defaultOpen?: boolean | string | number;
46
+ defaultopen?: boolean | string | number;
47
+ openTitle?: string;
48
+ opentitle?: string;
49
+ closeTitle?: string;
50
+ closetitle?: string;
51
+ children?: ReactNode;
52
+ className?: string;
53
+ }) {
54
+ const baseLabel = (title ?? summary ?? 'Expand').trim();
55
+ const closedLabel = closeTitle ?? closetitle ?? deriveClosedLabel(baseLabel);
56
+ const expandedLabel = openTitle ?? opentitle ?? deriveOpenLabel(closedLabel);
57
+ const startsOpen = normalizeBool(defaultOpen ?? defaultopen);
58
+ const [isOpen, setIsOpen] = useState(startsOpen);
59
+
60
+ const summaryText = useMemo(
61
+ () => (isOpen ? expandedLabel : closedLabel),
62
+ [isOpen, expandedLabel, closedLabel],
63
+ );
64
+
65
+ return (
66
+ <details
67
+ className={['velu-expandable', className].filter(Boolean).join(' ')}
68
+ open={isOpen}
69
+ onToggle={(event) => {
70
+ setIsOpen((event.currentTarget as HTMLDetailsElement).open);
71
+ }}
72
+ >
73
+ <summary>{summaryText}</summary>
74
+ <div className="velu-expandable-content">{children}</div>
75
+ </details>
76
+ );
77
+ }