@aravindc26/velu 0.10.0 → 0.11.1

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 (65) hide show
  1. package/package.json +15 -6
  2. package/schema/velu.schema.json +1864 -30
  3. package/src/build.ts +1161 -180
  4. package/src/cli.ts +121 -16
  5. package/src/engine/_server.mjs +1708 -192
  6. package/src/engine/app/(docs)/[...slug]/layout.tsx +377 -0
  7. package/src/engine/app/(docs)/[...slug]/page.tsx +917 -0
  8. package/src/engine/app/(docs)/layout.tsx +1 -13
  9. package/src/engine/app/api/proxy/route.ts +23 -0
  10. package/src/engine/app/copy-page.css +59 -1
  11. package/src/engine/app/global.css +3487 -6
  12. package/src/engine/app/layout.tsx +59 -8
  13. package/src/engine/app/llms-file/route.ts +87 -0
  14. package/src/engine/app/llms-full-file/route.ts +62 -0
  15. package/src/engine/app/md-file/[...slug]/route.ts +409 -0
  16. package/src/engine/app/page.tsx +45 -0
  17. package/src/engine/app/robots.txt/route.ts +61 -0
  18. package/src/engine/app/rss-file/[...slug]/route.ts +176 -0
  19. package/src/engine/app/search.css +20 -0
  20. package/src/engine/app/sitemap.xml/route.ts +80 -0
  21. package/src/engine/components/assistant.tsx +16 -5
  22. package/src/engine/components/changelog-filters.tsx +114 -0
  23. package/src/engine/components/code-group.tsx +383 -0
  24. package/src/engine/components/color.tsx +118 -0
  25. package/src/engine/components/expandable.tsx +77 -0
  26. package/src/engine/components/icon.tsx +136 -0
  27. package/src/engine/components/image-zoom-fallback.tsx +147 -0
  28. package/src/engine/components/image.tsx +111 -0
  29. package/src/engine/components/lang-switcher.tsx +95 -0
  30. package/src/engine/components/manual-api-playground.tsx +154 -0
  31. package/src/engine/components/mermaid.tsx +142 -0
  32. package/src/engine/components/openapi-toc-sync.tsx +59 -0
  33. package/src/engine/components/openapi.tsx +1679 -0
  34. package/src/engine/components/page-feedback.tsx +153 -0
  35. package/src/engine/components/product-switcher.tsx +102 -0
  36. package/src/engine/components/prompt.tsx +90 -0
  37. package/src/engine/components/providers.tsx +21 -0
  38. package/src/engine/components/search.tsx +70 -3
  39. package/src/engine/components/sidebar-links.tsx +49 -0
  40. package/src/engine/components/synced-tabs.tsx +57 -0
  41. package/src/engine/components/theme-toggle.tsx +39 -0
  42. package/src/engine/components/toc-examples.tsx +110 -0
  43. package/src/engine/components/version-switcher.tsx +89 -0
  44. package/src/engine/components/view.tsx +344 -0
  45. package/src/engine/generated/redirects.ts +3 -0
  46. package/src/engine/lib/changelog.ts +246 -0
  47. package/src/engine/lib/layout.shared.ts +57 -7
  48. package/src/engine/lib/llms.ts +444 -0
  49. package/src/engine/lib/navigation-normalize.mjs +525 -0
  50. package/src/engine/lib/navigation-normalize.ts +695 -0
  51. package/src/engine/lib/redirects.ts +194 -0
  52. package/src/engine/lib/source.ts +121 -4
  53. package/src/engine/lib/velu.ts +635 -5
  54. package/src/engine/mdx-components.tsx +648 -0
  55. package/src/engine/middleware.ts +66 -0
  56. package/src/engine/next.config.mjs +2 -2
  57. package/src/engine/public/icons/cursor-dark.svg +12 -0
  58. package/src/engine/public/icons/cursor-light.svg +12 -0
  59. package/src/engine/source.config.ts +98 -1
  60. package/src/engine/src/components/PageTitle.astro +16 -5
  61. package/src/engine/src/lib/velu.ts +97 -16
  62. package/src/navigation-normalize.ts +686 -0
  63. package/src/themes.ts +6 -6
  64. package/src/validate.ts +235 -24
  65. package/src/engine/app/(docs)/[[...slug]]/page.tsx +0 -69
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import { usePathname } from 'next/navigation';
4
+ import { useEffect } from 'react';
5
+
6
+ export function TocExamples() {
7
+ const pathname = usePathname();
8
+
9
+ useEffect(() => {
10
+ const page = document.getElementById('nd-page');
11
+ const toc = document.getElementById('nd-toc');
12
+ const layout = document.getElementById('nd-docs-layout');
13
+ if (!page || !toc) return;
14
+
15
+ let wrapper: HTMLDivElement | null = null;
16
+ let placeholders: Array<{ node: HTMLElement; marker: Comment }> = [];
17
+ const media = window.matchMedia('(min-width: 1024px)');
18
+
19
+ const isTocVisible = () => {
20
+ if (!media.matches) return false;
21
+ const styles = window.getComputedStyle(toc);
22
+ return styles.display !== 'none' && styles.visibility !== 'hidden';
23
+ };
24
+
25
+ const mountIntoToc = () => {
26
+ if (wrapper) return;
27
+ const examples = Array.from(
28
+ page.querySelectorAll<HTMLElement>('.velu-request-example, .velu-response-example, .velu-panel'),
29
+ );
30
+ if (examples.length === 0) return;
31
+
32
+ wrapper = document.createElement('div');
33
+ wrapper.className = 'velu-toc-examples';
34
+ placeholders = [];
35
+
36
+ for (const node of examples) {
37
+ const marker = document.createComment('velu-example-placeholder');
38
+ node.parentNode?.insertBefore(marker, node);
39
+ placeholders.push({ node, marker });
40
+ node.classList.add('velu-in-toc-example');
41
+ wrapper.appendChild(node);
42
+ }
43
+
44
+ toc.classList.add('velu-toc-replaced');
45
+ page.classList.add('velu-page-with-toc-examples');
46
+ if (layout) layout.style.setProperty('--fd-toc-width', '420px');
47
+ toc.appendChild(wrapper);
48
+ };
49
+
50
+ const restoreToPage = () => {
51
+ if (wrapper) {
52
+ for (const { node, marker } of placeholders) {
53
+ if (marker.parentNode) {
54
+ marker.parentNode.insertBefore(node, marker);
55
+ marker.parentNode.removeChild(marker);
56
+ }
57
+ node.classList.remove('velu-in-toc-example');
58
+ }
59
+ wrapper.remove();
60
+ wrapper = null;
61
+ placeholders = [];
62
+ }
63
+
64
+ // Defensive cleanup in case of interrupted transitions.
65
+ toc.querySelectorAll('.velu-toc-examples').forEach((node) => node.remove());
66
+ document.querySelectorAll('.velu-in-toc-example').forEach((node) => {
67
+ node.classList.remove('velu-in-toc-example');
68
+ });
69
+
70
+ toc.classList.remove('velu-toc-replaced');
71
+ page.classList.remove('velu-page-with-toc-examples');
72
+ if (layout) layout.style.removeProperty('--fd-toc-width');
73
+ };
74
+
75
+ const syncPlacement = () => {
76
+ if (isTocVisible()) {
77
+ mountIntoToc();
78
+ } else {
79
+ restoreToPage();
80
+ }
81
+ };
82
+
83
+ syncPlacement();
84
+ if (media.addEventListener) {
85
+ media.addEventListener('change', syncPlacement);
86
+ } else {
87
+ // Fallback for older MediaQueryList implementations.
88
+ (media as any).addListener(syncPlacement);
89
+ }
90
+ window.addEventListener('resize', syncPlacement);
91
+ window.addEventListener('orientationchange', syncPlacement);
92
+ document.addEventListener('visibilitychange', syncPlacement);
93
+ const intervalId = window.setInterval(syncPlacement, 600);
94
+
95
+ return () => {
96
+ if (media.removeEventListener) {
97
+ media.removeEventListener('change', syncPlacement);
98
+ } else {
99
+ (media as any).removeListener(syncPlacement);
100
+ }
101
+ window.removeEventListener('resize', syncPlacement);
102
+ window.removeEventListener('orientationchange', syncPlacement);
103
+ document.removeEventListener('visibilitychange', syncPlacement);
104
+ window.clearInterval(intervalId);
105
+ restoreToPage();
106
+ };
107
+ }, [pathname]);
108
+
109
+ return null;
110
+ }
@@ -0,0 +1,89 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
+ import { usePathname } from 'next/navigation';
5
+ import type { VeluVersionOption } from '@/lib/velu';
6
+
7
+ function VersionIcon() {
8
+ return (
9
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
10
+ <path d="M4 6h16" />
11
+ <path d="M4 12h16" />
12
+ <path d="M4 18h16" />
13
+ </svg>
14
+ );
15
+ }
16
+
17
+ function ChevronDownIcon() {
18
+ return (
19
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
20
+ <path d="m6 9 6 6 6-6" />
21
+ </svg>
22
+ );
23
+ }
24
+
25
+ export function VersionSwitcher({ versions }: { versions: VeluVersionOption[] }) {
26
+ const pathname = usePathname();
27
+ const [open, setOpen] = useState(false);
28
+ const ref = useRef<HTMLDivElement>(null);
29
+
30
+ const fallback = useMemo(() => versions.find((v) => v.isDefault) ?? versions[0], [versions]);
31
+
32
+ const current = useMemo(() => {
33
+ const firstSeg = pathname.split('/').filter(Boolean)[0] ?? '';
34
+ return versions.find((version) => version.tabSlugs.includes(firstSeg)) ?? fallback;
35
+ }, [pathname, versions, fallback]);
36
+
37
+ useEffect(() => {
38
+ function handleClick(e: MouseEvent) {
39
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
40
+ }
41
+ document.addEventListener('mousedown', handleClick);
42
+ return () => document.removeEventListener('mousedown', handleClick);
43
+ }, []);
44
+
45
+ if (!fallback || versions.length <= 1) return null;
46
+
47
+ function switchTo(target: VeluVersionOption) {
48
+ setOpen(false);
49
+
50
+ const segments = pathname.split('/').filter(Boolean);
51
+ const firstSeg = segments[0] ?? '';
52
+
53
+ if (current && current.tabSlugs.includes(firstSeg)) {
54
+ const index = current.tabSlugs.indexOf(firstSeg);
55
+ const targetTab = target.tabSlugs[index] ?? target.tabSlugs[0];
56
+ if (targetTab) {
57
+ const rest = segments.slice(1);
58
+ window.location.href = '/' + [targetTab, ...rest].join('/');
59
+ return;
60
+ }
61
+ }
62
+
63
+ window.location.href = target.defaultPath;
64
+ }
65
+
66
+ return (
67
+ <div className="velu-version-switcher-wrap" ref={ref}>
68
+ <button type="button" className="velu-version-switcher" onClick={() => setOpen((v) => !v)}>
69
+ <VersionIcon />
70
+ <span>{current.version}</span>
71
+ <ChevronDownIcon />
72
+ </button>
73
+ {open && (
74
+ <div className="velu-version-menu">
75
+ {versions.map((version) => (
76
+ <button
77
+ key={version.slug}
78
+ type="button"
79
+ className={`velu-version-option ${version.slug === current.slug ? 'active' : ''}`}
80
+ onClick={() => switchTo(version)}
81
+ >
82
+ {version.version}
83
+ </button>
84
+ ))}
85
+ </div>
86
+ )}
87
+ </div>
88
+ );
89
+ }
@@ -0,0 +1,344 @@
1
+ "use client";
2
+
3
+ import { createPortal } from "react-dom";
4
+ import { useEffect, useMemo, useState, type ReactNode } from "react";
5
+ import { VeluIcon } from "@/components/icon";
6
+
7
+ const VELU_VIEW_OPTIONS_KEY = "__veluViewOptions";
8
+ const VELU_VIEW_OPTIONS_EVENT = "velu:view-options";
9
+ const VELU_VIEW_TOC_HOST_ID = "velu-view-toc-host";
10
+ const VELU_TAB_SYNC_KEY = "__veluTabSyncLabel";
11
+ const VELU_TAB_SYNC_EVENT = "velu:tab-sync";
12
+
13
+ type ViewOption = {
14
+ title: string;
15
+ icon?: string;
16
+ iconType?: string;
17
+ };
18
+
19
+ const VIEW_ICON_URL: Record<string, string> = {
20
+ javascript: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/javascript/javascript-original.svg",
21
+ typescript: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/typescript/typescript-original.svg",
22
+ python: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/python/python-original.svg",
23
+ java: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/java/java-original.svg",
24
+ ruby: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/ruby/ruby-original.svg",
25
+ shell: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/bash/bash-original.svg",
26
+ bash: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/bash/bash-original.svg",
27
+ yaml: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/yaml/yaml-original.svg",
28
+ markdown: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/markdown/markdown-original.svg",
29
+ };
30
+
31
+ const VIEW_ICON_ALIAS: Record<string, string> = {
32
+ js: "javascript",
33
+ jsx: "javascript",
34
+ javascript: "javascript",
35
+ ts: "typescript",
36
+ tsx: "typescript",
37
+ typescript: "typescript",
38
+ py: "python",
39
+ python: "python",
40
+ java: "java",
41
+ rb: "ruby",
42
+ ruby: "ruby",
43
+ sh: "shell",
44
+ shell: "shell",
45
+ bash: "bash",
46
+ yml: "yaml",
47
+ yaml: "yaml",
48
+ md: "markdown",
49
+ markdown: "markdown",
50
+ };
51
+
52
+ function normalizeTitle(value: string): string {
53
+ return value.trim().toLowerCase();
54
+ }
55
+
56
+ function normalizeIconKey(value: string | undefined): string | undefined {
57
+ if (!value || !value.trim()) return undefined;
58
+ const key = value.trim().toLowerCase();
59
+ return VIEW_ICON_ALIAS[key] ?? key;
60
+ }
61
+
62
+ function resolveViewIconUrl(icon: string | undefined, title: string): string | undefined {
63
+ const iconKey = normalizeIconKey(icon);
64
+ if (iconKey && VIEW_ICON_URL[iconKey]) return VIEW_ICON_URL[iconKey];
65
+
66
+ const titleKey = normalizeIconKey(title);
67
+ if (titleKey && VIEW_ICON_URL[titleKey]) return VIEW_ICON_URL[titleKey];
68
+ return undefined;
69
+ }
70
+
71
+ function ViewOptionIcon({ title, icon, iconType }: { title: string; icon?: string; iconType?: string }) {
72
+ const src = resolveViewIconUrl(icon, title);
73
+ if (src) {
74
+ return <img src={src} alt="" aria-hidden="true" className="velu-view-option-icon velu-view-option-icon-img" loading="lazy" decoding="async" />;
75
+ }
76
+ if (!icon) return null;
77
+ return <VeluIcon name={icon} iconType={iconType} className="velu-view-option-icon" />;
78
+ }
79
+
80
+ function readSharedOptions(): ViewOption[] {
81
+ if (typeof window === "undefined") return [];
82
+ const value = (window as any)[VELU_VIEW_OPTIONS_KEY];
83
+ if (!Array.isArray(value)) return [];
84
+ return value.filter((item) => item && typeof item.title === "string" && item.title.trim());
85
+ }
86
+
87
+ function writeSharedOptions(options: ViewOption[]) {
88
+ if (typeof window === "undefined") return;
89
+ (window as any)[VELU_VIEW_OPTIONS_KEY] = options;
90
+ }
91
+
92
+ function upsertSharedOption(option: ViewOption): ViewOption[] {
93
+ const existing = readSharedOptions();
94
+ const key = normalizeTitle(option.title);
95
+ const next = [...existing];
96
+ const index = next.findIndex((item) => normalizeTitle(item.title) === key);
97
+ if (index >= 0) {
98
+ next[index] = { ...next[index], ...option };
99
+ } else {
100
+ next.push(option);
101
+ }
102
+ writeSharedOptions(next);
103
+ return next;
104
+ }
105
+
106
+ function broadcastOptions(options: ViewOption[]) {
107
+ if (typeof window === "undefined") return;
108
+ window.dispatchEvent(new CustomEvent(VELU_VIEW_OPTIONS_EVENT, { detail: { options } }));
109
+ }
110
+
111
+ function readSharedSelected(): string | null {
112
+ if (typeof window === "undefined") return null;
113
+ const value = (window as any)[VELU_TAB_SYNC_KEY];
114
+ return typeof value === "string" && value.trim() ? value : null;
115
+ }
116
+
117
+ function writeSharedSelected(title: string) {
118
+ if (typeof window === "undefined") return;
119
+ (window as any)[VELU_TAB_SYNC_KEY] = title;
120
+ }
121
+
122
+ function broadcastSelected(title: string) {
123
+ if (typeof window === "undefined") return;
124
+ const existing = readSharedSelected();
125
+ if (existing && normalizeTitle(existing) === normalizeTitle(title)) return;
126
+ writeSharedSelected(title);
127
+ window.dispatchEvent(new CustomEvent(VELU_TAB_SYNC_EVENT, { detail: { label: title } }));
128
+ }
129
+
130
+ function syncTocForVisibleHeadings() {
131
+ if (typeof document === "undefined") return;
132
+ const toc = document.getElementById("nd-toc");
133
+ if (!toc) return;
134
+
135
+ const links = Array.from(toc.querySelectorAll<HTMLAnchorElement>('a[href^="#"]'));
136
+ for (const link of links) {
137
+ const href = link.getAttribute("href");
138
+ if (!href || href === "#") continue;
139
+ const id = decodeURIComponent(href.slice(1));
140
+ const target = document.getElementById(id);
141
+ const row = link.closest("li") ?? link.parentElement;
142
+ if (!row) continue;
143
+ row.style.display = target ? "" : "none";
144
+ }
145
+ }
146
+
147
+ function ensureTocHost(): HTMLDivElement | null {
148
+ if (typeof document === "undefined") return null;
149
+ const toc = document.getElementById("nd-toc");
150
+ if (!toc) return null;
151
+
152
+ let host = document.getElementById(VELU_VIEW_TOC_HOST_ID) as HTMLDivElement | null;
153
+ if (!host) {
154
+ host = document.createElement("div");
155
+ host.id = VELU_VIEW_TOC_HOST_ID;
156
+ host.className = "velu-view-toc-host";
157
+ toc.prepend(host);
158
+ }
159
+ return host;
160
+ }
161
+
162
+ export function VeluView({
163
+ title,
164
+ icon,
165
+ iconType,
166
+ children,
167
+ className,
168
+ }: {
169
+ title?: string;
170
+ icon?: string;
171
+ iconType?: string;
172
+ children?: ReactNode;
173
+ className?: string;
174
+ }) {
175
+ const resolvedTitle = (typeof title === "string" && title.trim()) ? title.trim() : "View";
176
+ const normalizedResolvedTitle = useMemo(() => normalizeTitle(resolvedTitle), [resolvedTitle]);
177
+ const [selectedTitle, setSelectedTitle] = useState<string>(resolvedTitle);
178
+ const [menuOpen, setMenuOpen] = useState(false);
179
+ const [tocHost, setTocHost] = useState<HTMLDivElement | null>(null);
180
+ const [options, setOptions] = useState<ViewOption[]>(() => {
181
+ const initial = readSharedOptions();
182
+ return initial.length ? initial : [{ title: resolvedTitle, icon, iconType }];
183
+ });
184
+
185
+ useEffect(() => {
186
+ const nextOptions = upsertSharedOption({ title: resolvedTitle, icon, iconType });
187
+ setOptions(nextOptions);
188
+ broadcastOptions(nextOptions);
189
+
190
+ const existingSelected = readSharedSelected();
191
+ const hasExistingSelection = Boolean(
192
+ existingSelected && nextOptions.some((option) => normalizeTitle(option.title) === normalizeTitle(existingSelected)),
193
+ );
194
+
195
+ if (existingSelected && hasExistingSelection) {
196
+ setSelectedTitle(existingSelected);
197
+ } else {
198
+ setSelectedTitle(resolvedTitle);
199
+ broadcastSelected(resolvedTitle);
200
+ }
201
+ queueMicrotask(syncTocForVisibleHeadings);
202
+
203
+ const onSelected = (event: Event) => {
204
+ const detail = (event as CustomEvent<{ title?: string; label?: string }>).detail;
205
+ const next = detail?.label ?? detail?.title;
206
+ if (!next || !next.trim()) return;
207
+ setSelectedTitle(next);
208
+ queueMicrotask(syncTocForVisibleHeadings);
209
+ };
210
+
211
+ const onOptions = (event: Event) => {
212
+ const incoming = (event as CustomEvent<{ options?: ViewOption[] }>).detail?.options;
213
+ const next = Array.isArray(incoming) ? incoming : readSharedOptions();
214
+ setOptions(next.length ? next : [{ title: resolvedTitle, icon, iconType }]);
215
+ };
216
+
217
+ window.addEventListener(VELU_TAB_SYNC_EVENT, onSelected);
218
+ window.addEventListener(VELU_VIEW_OPTIONS_EVENT, onOptions);
219
+ return () => {
220
+ window.removeEventListener(VELU_TAB_SYNC_EVENT, onSelected);
221
+ window.removeEventListener(VELU_VIEW_OPTIONS_EVENT, onOptions);
222
+ };
223
+ }, [resolvedTitle, icon, iconType]);
224
+
225
+ useEffect(() => {
226
+ let frame = 0;
227
+ const attachHost = () => {
228
+ const host = ensureTocHost();
229
+ if (host) {
230
+ setTocHost(host);
231
+ return;
232
+ }
233
+ frame = window.requestAnimationFrame(attachHost);
234
+ };
235
+ attachHost();
236
+ return () => {
237
+ if (frame) window.cancelAnimationFrame(frame);
238
+ };
239
+ }, []);
240
+
241
+ useEffect(() => {
242
+ if (!menuOpen) return;
243
+ const onPointerDown = (event: PointerEvent) => {
244
+ const target = event.target as Element | null;
245
+ if (target?.closest(`#${VELU_VIEW_TOC_HOST_ID}`)) return;
246
+ setMenuOpen(false);
247
+ };
248
+ const onEscape = (event: KeyboardEvent) => {
249
+ if (event.key === "Escape") setMenuOpen(false);
250
+ };
251
+ window.addEventListener("pointerdown", onPointerDown);
252
+ window.addEventListener("keydown", onEscape);
253
+ return () => {
254
+ window.removeEventListener("pointerdown", onPointerDown);
255
+ window.removeEventListener("keydown", onEscape);
256
+ };
257
+ }, [menuOpen]);
258
+
259
+ useEffect(() => {
260
+ if (!options.length) return;
261
+ const hasCurrent = options.some((option) => normalizeTitle(option.title) === normalizeTitle(selectedTitle));
262
+ if (hasCurrent) return;
263
+ const fallback = options[0]?.title;
264
+ if (!fallback) return;
265
+ setSelectedTitle(fallback);
266
+ broadcastSelected(fallback);
267
+ queueMicrotask(syncTocForVisibleHeadings);
268
+ }, [options, selectedTitle]);
269
+
270
+ const effectiveOptions = options.length ? options : [{ title: resolvedTitle, icon, iconType }];
271
+ const isActive = normalizeTitle(selectedTitle) === normalizedResolvedTitle;
272
+ const selectedOption = effectiveOptions.find((option) => normalizeTitle(option.title) === normalizeTitle(selectedTitle))
273
+ ?? effectiveOptions[0];
274
+
275
+ const selectView = (nextTitle: string) => {
276
+ if (!nextTitle || normalizeTitle(nextTitle) === normalizeTitle(selectedTitle)) return;
277
+ setSelectedTitle(nextTitle);
278
+ setMenuOpen(false);
279
+ broadcastSelected(nextTitle);
280
+ queueMicrotask(syncTocForVisibleHeadings);
281
+ };
282
+
283
+ const viewSwitcher = (
284
+ tocHost && isActive && effectiveOptions.length > 1
285
+ ? createPortal(
286
+ <div className="velu-view-toc-switcher">
287
+ <button
288
+ type="button"
289
+ className="velu-view-toc-trigger"
290
+ aria-haspopup="listbox"
291
+ aria-expanded={menuOpen}
292
+ onClick={() => setMenuOpen((prev) => !prev)}
293
+ >
294
+ {selectedOption ? <ViewOptionIcon title={selectedOption.title} icon={selectedOption.icon} iconType={selectedOption.iconType} /> : null}
295
+ <span>{selectedOption?.title ?? "View"}</span>
296
+ <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={["velu-view-toc-chevron", menuOpen ? "open" : ""].join(" ")}>
297
+ <path d="m6 8 4 4 4-4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
298
+ </svg>
299
+ </button>
300
+ {menuOpen ? (
301
+ <div className="velu-view-toc-menu" role="listbox" aria-label="Select view">
302
+ {effectiveOptions.map((option) => {
303
+ const key = normalizeTitle(option.title);
304
+ const selected = normalizeTitle(selectedTitle) === key;
305
+ return (
306
+ <button
307
+ key={key}
308
+ type="button"
309
+ role="option"
310
+ aria-selected={selected}
311
+ className={["velu-view-toc-option", selected ? "active" : ""].filter(Boolean).join(" ")}
312
+ onClick={() => selectView(option.title)}
313
+ >
314
+ <span className="velu-view-toc-option-main">
315
+ <ViewOptionIcon title={option.title} icon={option.icon} iconType={option.iconType} />
316
+ <span>{option.title}</span>
317
+ </span>
318
+ {selected ? (
319
+ <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="velu-view-check">
320
+ <path d="m4.5 10.5 3.2 3.2 7.8-7.8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
321
+ </svg>
322
+ ) : null}
323
+ </button>
324
+ );
325
+ })}
326
+ </div>
327
+ ) : null}
328
+ </div>,
329
+ tocHost,
330
+ )
331
+ : null
332
+ );
333
+
334
+ if (!isActive) {
335
+ return viewSwitcher;
336
+ }
337
+
338
+ return (
339
+ <section className={["velu-view", className].filter(Boolean).join(" ")} data-velu-view={resolvedTitle}>
340
+ {viewSwitcher}
341
+ <div className="velu-view-content">{children}</div>
342
+ </section>
343
+ );
344
+ }
@@ -0,0 +1,3 @@
1
+ const redirects: Array<{ source: string; destination: string; permanent?: boolean }> = [];
2
+
3
+ export default redirects;