@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.
- package/package.json +15 -6
- package/schema/velu.schema.json +1251 -115
- package/src/build.ts +1121 -304
- package/src/cli.ts +90 -26
- package/src/engine/_server.mjs +1684 -277
- package/src/engine/app/(docs)/[...slug]/layout.tsx +371 -0
- package/src/engine/app/(docs)/[...slug]/page.tsx +926 -0
- package/src/engine/app/api/proxy/route.ts +23 -0
- package/src/engine/app/copy-page.css +59 -1
- package/src/engine/app/global.css +3157 -3
- package/src/engine/app/layout.tsx +56 -1
- package/src/engine/app/llms-file/route.ts +87 -0
- package/src/engine/app/llms-full-file/route.ts +62 -0
- package/src/engine/app/md-file/[...slug]/route.ts +409 -0
- package/src/engine/app/page.tsx +45 -0
- package/src/engine/app/robots.txt/route.ts +63 -0
- package/src/engine/app/rss-file/[...slug]/route.ts +169 -0
- package/src/engine/app/sitemap.xml/route.ts +82 -0
- package/src/engine/components/assistant.tsx +16 -5
- package/src/engine/components/changelog-filters.tsx +114 -0
- package/src/engine/components/code-group.tsx +383 -0
- package/src/engine/components/color.tsx +118 -0
- package/src/engine/components/expandable.tsx +77 -0
- package/src/engine/components/icon.tsx +136 -0
- package/src/engine/components/image-zoom-fallback.tsx +147 -0
- package/src/engine/components/image.tsx +111 -0
- package/src/engine/components/manual-api-playground.tsx +154 -0
- package/src/engine/components/mermaid.tsx +142 -0
- package/src/engine/components/openapi-toc-sync.tsx +59 -0
- package/src/engine/components/openapi.tsx +1682 -0
- package/src/engine/components/page-feedback.tsx +153 -0
- package/src/engine/components/product-switcher.tsx +27 -3
- package/src/engine/components/prompt.tsx +90 -0
- package/src/engine/components/providers.tsx +1 -6
- package/src/engine/components/search.tsx +4 -0
- package/src/engine/components/sidebar-links.tsx +13 -15
- package/src/engine/components/synced-tabs.tsx +57 -0
- package/src/engine/components/toc-examples.tsx +110 -0
- package/src/engine/components/view.tsx +344 -0
- package/src/engine/generated/redirects.ts +3 -0
- package/src/engine/lib/changelog.ts +246 -0
- package/src/engine/lib/layout.shared.ts +30 -2
- package/src/engine/lib/llms.ts +444 -0
- package/src/engine/lib/navigation-normalize.mjs +481 -412
- package/src/engine/lib/navigation-normalize.ts +261 -54
- package/src/engine/lib/redirects.ts +194 -0
- package/src/engine/lib/source.ts +107 -4
- package/src/engine/lib/velu.ts +368 -2
- package/src/engine/mdx-components.tsx +648 -0
- package/src/engine/middleware.ts +66 -0
- package/src/engine/public/icons/cursor-dark.svg +12 -0
- package/src/engine/public/icons/cursor-light.svg +12 -0
- package/src/engine/source.config.ts +98 -1
- package/src/engine/src/components/PageTitle.astro +16 -5
- package/src/engine/src/lib/velu.ts +11 -3
- package/src/navigation-normalize.ts +252 -54
- package/src/themes.ts +6 -6
- package/src/validate.ts +119 -6
- package/src/engine/app/(docs)/[[...slug]]/layout.tsx +0 -87
- 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
|
+
}
|