@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,136 @@
1
+ import type { LucideIcon } from 'lucide-react';
2
+ import {
3
+ Apple,
4
+ ArrowRight,
5
+ BookOpen,
6
+ CircleHelp,
7
+ Code2,
8
+ Download,
9
+ ExternalLink,
10
+ Flag,
11
+ Frame,
12
+ Lightbulb,
13
+ Hand,
14
+ Layers3,
15
+ Lock,
16
+ Newspaper,
17
+ Play,
18
+ Rocket,
19
+ Send,
20
+ Smartphone,
21
+ SquareTerminal,
22
+ Sparkles,
23
+ TriangleAlert,
24
+ Webhook,
25
+ } from 'lucide-react';
26
+ import type { VeluIconLibrary } from '@/lib/velu';
27
+
28
+ const ICONS: Record<string, LucideIcon> = {
29
+ apple: Apple,
30
+ 'arrow-right': ArrowRight,
31
+ 'book-open': BookOpen,
32
+ 'code-2': Code2,
33
+ download: Download,
34
+ 'external-link': ExternalLink,
35
+ flag: Flag,
36
+ frame: Frame,
37
+ lightbulb: Lightbulb,
38
+ hand: Hand,
39
+ 'layers-3': Layers3,
40
+ lock: Lock,
41
+ newspaper: Newspaper,
42
+ play: Play,
43
+ rocket: Rocket,
44
+ send: Send,
45
+ smartphone: Smartphone,
46
+ 'square-terminal': SquareTerminal,
47
+ sparkles: Sparkles,
48
+ 'triangle-alert': TriangleAlert,
49
+ webhook: Webhook,
50
+ };
51
+
52
+ const ALIASES: Record<string, string> = {
53
+ api: 'code-2',
54
+ bulb: 'lightbulb',
55
+ 'book-open-cover': 'book-open',
56
+ 'light-bulb': 'lightbulb',
57
+ 'hand-index-finger': 'hand',
58
+ 'hand-finger-right': 'hand',
59
+ 'hand-point-right': 'hand',
60
+ 'layer-group': 'layers-3',
61
+ };
62
+
63
+ function normalizeIconName(name: string): string {
64
+ return name.toLowerCase().trim().replace(/[_\s]+/g, '-');
65
+ }
66
+
67
+ export function VeluIcon({
68
+ name,
69
+ library = 'fontawesome',
70
+ iconType,
71
+ color,
72
+ className,
73
+ fallback = true,
74
+ }: {
75
+ name?: string;
76
+ library?: VeluIconLibrary;
77
+ iconType?: string;
78
+ color?: string;
79
+ className?: string;
80
+ fallback?: boolean;
81
+ }) {
82
+ if (!name) {
83
+ return fallback ? <CircleHelp className={className} style={color ? { color } : undefined} aria-hidden="true" /> : null;
84
+ }
85
+
86
+ const normalized = normalizeIconName(name);
87
+ if (/^(https?:\/\/|\/|\.{1,2}\/)/.test(name) || /\.(svg|png|jpg|jpeg|webp|gif)$/i.test(name)) {
88
+ return <img src={name} alt="" className={className} aria-hidden="true" />;
89
+ }
90
+
91
+ const canonical = ALIASES[normalized] ?? normalized;
92
+ const Icon = ICONS[canonical];
93
+
94
+ if (Icon) return <Icon className={className} style={color ? { color } : undefined} aria-hidden="true" />;
95
+
96
+ const faPrefixByType: Record<string, string> = {
97
+ brands: 'fa6-brands',
98
+ regular: 'fa6-regular',
99
+ solid: 'fa6-solid',
100
+ light: 'fa6-light',
101
+ thin: 'fa6-thin',
102
+ 'sharp-solid': 'fa6-sharp-solid',
103
+ duotone: 'fa6-duotone',
104
+ };
105
+
106
+ const prefix =
107
+ library === 'lucide'
108
+ ? 'lucide'
109
+ : library === 'tabler'
110
+ ? 'tabler'
111
+ : faPrefixByType[(iconType ?? '').toLowerCase()] ?? 'fa6-solid';
112
+
113
+ const iconifyName = canonical.replace(/^fa-/, '');
114
+ const iconifyUrl = `https://api.iconify.design/${prefix}:${iconifyName}.svg`;
115
+
116
+ return (
117
+ <span
118
+ className={className}
119
+ aria-hidden="true"
120
+ style={{
121
+ display: 'inline-block',
122
+ width: '1em',
123
+ height: '1em',
124
+ backgroundColor: color ?? 'currentColor',
125
+ WebkitMaskImage: `url("${iconifyUrl}")`,
126
+ maskImage: `url("${iconifyUrl}")`,
127
+ WebkitMaskRepeat: 'no-repeat',
128
+ maskRepeat: 'no-repeat',
129
+ WebkitMaskPosition: 'center',
130
+ maskPosition: 'center',
131
+ WebkitMaskSize: 'contain',
132
+ maskSize: 'contain',
133
+ }}
134
+ />
135
+ );
136
+ }
@@ -0,0 +1,147 @@
1
+ "use client";
2
+
3
+ import { createPortal } from "react-dom";
4
+ import { useEffect, useState } from "react";
5
+
6
+ type LightboxImage = {
7
+ src: string;
8
+ alt: string;
9
+ };
10
+
11
+ function hasNoZoom(img: HTMLImageElement): boolean {
12
+ return img.hasAttribute("noZoom")
13
+ || img.hasAttribute("nozoom")
14
+ || img.getAttribute("data-no-zoom") === "true";
15
+ }
16
+
17
+ function shouldSkipImage(img: HTMLImageElement): boolean {
18
+ if (!img.src) return true;
19
+ if (img.classList.contains("velu-image-zoomable")) return true; // handled by VeluImage
20
+ if (hasNoZoom(img)) return true;
21
+ if (img.closest("pre, code, .shiki")) return true;
22
+ if (img.closest(".velu-image-lightbox")) return true;
23
+ if (img.classList.contains("velu-view-option-icon-img") || img.classList.contains("velu-lang-icon-img")) return true;
24
+
25
+ const rect = img.getBoundingClientRect();
26
+ if (rect.width > 0 && rect.height > 0 && (rect.width < 96 || rect.height < 56)) return true;
27
+ return false;
28
+ }
29
+
30
+ function applyZoomableMarkers(root: ParentNode) {
31
+ const images = root.querySelectorAll<HTMLImageElement>("#nd-page img");
32
+ for (const img of images) {
33
+ if (shouldSkipImage(img)) {
34
+ img.classList.remove("velu-image-zoomable-fallback");
35
+ if (img.getAttribute("data-velu-zoom-fallback") === "1") {
36
+ img.removeAttribute("data-velu-zoom-fallback");
37
+ if (img.getAttribute("role") === "button") img.removeAttribute("role");
38
+ }
39
+ continue;
40
+ }
41
+
42
+ img.classList.add("velu-image-zoomable-fallback");
43
+ if (!img.hasAttribute("tabindex")) img.tabIndex = 0;
44
+ if (!img.hasAttribute("role")) img.setAttribute("role", "button");
45
+ img.setAttribute("data-velu-zoom-fallback", "1");
46
+ }
47
+ }
48
+
49
+ export function VeluImageZoomFallback() {
50
+ const [mounted, setMounted] = useState(false);
51
+ const [activeImage, setActiveImage] = useState<LightboxImage | null>(null);
52
+
53
+ useEffect(() => {
54
+ setMounted(true);
55
+ }, []);
56
+
57
+ useEffect(() => {
58
+ const pageRoot = document.getElementById("nd-page");
59
+ if (!pageRoot) return;
60
+
61
+ const tryOpen = (img: HTMLImageElement) => {
62
+ if (shouldSkipImage(img)) return;
63
+ setActiveImage({
64
+ src: img.currentSrc || img.src,
65
+ alt: img.alt ?? "",
66
+ });
67
+ };
68
+
69
+ applyZoomableMarkers(document);
70
+
71
+ const observerOptions: MutationObserverInit = { subtree: true, childList: true, attributes: true, attributeFilter: ["class", "src", "noZoom", "nozoom"] };
72
+ const observer = new MutationObserver(() => {
73
+ observer.disconnect();
74
+ applyZoomableMarkers(document);
75
+ observer.observe(pageRoot, observerOptions);
76
+ });
77
+ observer.observe(pageRoot, observerOptions);
78
+
79
+ const onClickCapture = (event: MouseEvent) => {
80
+ const target = event.target as Element | null;
81
+ const img = target?.closest("img") as HTMLImageElement | null;
82
+ if (!img || !pageRoot.contains(img)) return;
83
+ if (!img.classList.contains("velu-image-zoomable-fallback")) return;
84
+
85
+ event.preventDefault();
86
+ event.stopPropagation();
87
+ tryOpen(img);
88
+ };
89
+
90
+ const onKeyDown = (event: KeyboardEvent) => {
91
+ if (event.key !== "Enter" && event.key !== " ") return;
92
+ const active = document.activeElement as HTMLImageElement | null;
93
+ if (!active || !pageRoot.contains(active)) return;
94
+ if (!(active instanceof HTMLImageElement)) return;
95
+ if (!active.classList.contains("velu-image-zoomable-fallback")) return;
96
+
97
+ event.preventDefault();
98
+ tryOpen(active);
99
+ };
100
+
101
+ pageRoot.addEventListener("click", onClickCapture, true);
102
+ pageRoot.addEventListener("keydown", onKeyDown);
103
+
104
+ return () => {
105
+ observer.disconnect();
106
+ pageRoot.removeEventListener("click", onClickCapture, true);
107
+ pageRoot.removeEventListener("keydown", onKeyDown);
108
+ };
109
+ }, []);
110
+
111
+ useEffect(() => {
112
+ if (!activeImage) return;
113
+ const prevOverflow = document.body.style.overflow;
114
+ const onEsc = (event: KeyboardEvent) => {
115
+ if (event.key === "Escape") setActiveImage(null);
116
+ };
117
+
118
+ document.body.style.overflow = "hidden";
119
+ window.addEventListener("keydown", onEsc);
120
+ return () => {
121
+ document.body.style.overflow = prevOverflow;
122
+ window.removeEventListener("keydown", onEsc);
123
+ };
124
+ }, [activeImage]);
125
+
126
+ if (!mounted || !activeImage) return null;
127
+
128
+ return createPortal(
129
+ <div className="velu-image-lightbox" role="dialog" aria-modal="true" onClick={() => setActiveImage(null)}>
130
+ <button
131
+ type="button"
132
+ className="velu-image-lightbox-close"
133
+ aria-label="Close image zoom"
134
+ onClick={() => setActiveImage(null)}
135
+ >
136
+ ×
137
+ </button>
138
+ <img
139
+ src={activeImage.src}
140
+ alt={activeImage.alt}
141
+ className="velu-image-lightbox-img"
142
+ onClick={(event) => event.stopPropagation()}
143
+ />
144
+ </div>,
145
+ document.body,
146
+ );
147
+ }
@@ -0,0 +1,111 @@
1
+ "use client";
2
+
3
+ import { createPortal } from "react-dom";
4
+ import { useEffect, useMemo, useState, type ImgHTMLAttributes, type KeyboardEvent, type MouseEvent } from "react";
5
+
6
+ type VeluImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
7
+ src?: string | { src?: string };
8
+ noZoom?: boolean | "" | "true" | "false";
9
+ };
10
+
11
+ function toImageSrc(value: VeluImageProps["src"]): string | undefined {
12
+ if (typeof value === "string") return value;
13
+ if (value && typeof value === "object" && typeof value.src === "string") return value.src;
14
+ return undefined;
15
+ }
16
+
17
+ function isNoZoom(value: VeluImageProps["noZoom"]): boolean {
18
+ if (value === true || value === "") return true;
19
+ if (typeof value === "string") return value.toLowerCase() === "true";
20
+ return false;
21
+ }
22
+
23
+ export function VeluImage({
24
+ src,
25
+ alt,
26
+ className,
27
+ noZoom,
28
+ onClick,
29
+ onKeyDown,
30
+ tabIndex,
31
+ role,
32
+ ...props
33
+ }: VeluImageProps) {
34
+ const [open, setOpen] = useState(false);
35
+ const [mounted, setMounted] = useState(false);
36
+ const resolvedSrc = useMemo(() => toImageSrc(src), [src]);
37
+ const zoomDisabled = isNoZoom(noZoom) || !resolvedSrc;
38
+ const isZoomable = !zoomDisabled;
39
+
40
+ useEffect(() => {
41
+ setMounted(true);
42
+ }, []);
43
+
44
+ useEffect(() => {
45
+ if (!open) return;
46
+ const previousOverflow = document.body.style.overflow;
47
+ const onEsc = (event: globalThis.KeyboardEvent) => {
48
+ if (event.key === "Escape") setOpen(false);
49
+ };
50
+
51
+ document.body.style.overflow = "hidden";
52
+ window.addEventListener("keydown", onEsc);
53
+ return () => {
54
+ document.body.style.overflow = previousOverflow;
55
+ window.removeEventListener("keydown", onEsc);
56
+ };
57
+ }, [open]);
58
+
59
+ const handleClick = (event: MouseEvent<HTMLImageElement>) => {
60
+ onClick?.(event);
61
+ if (event.defaultPrevented || !isZoomable) return;
62
+ event.preventDefault();
63
+ event.stopPropagation();
64
+ setOpen(true);
65
+ };
66
+
67
+ const handleKeyDown = (event: KeyboardEvent<HTMLImageElement>) => {
68
+ onKeyDown?.(event);
69
+ if (event.defaultPrevented || !isZoomable) return;
70
+ if (event.key !== "Enter" && event.key !== " ") return;
71
+ event.preventDefault();
72
+ setOpen(true);
73
+ };
74
+
75
+ return (
76
+ <>
77
+ <img
78
+ {...props}
79
+ src={resolvedSrc}
80
+ alt={alt}
81
+ className={[className, isZoomable ? "velu-image-zoomable" : ""].filter(Boolean).join(" ")}
82
+ onClick={handleClick}
83
+ onKeyDown={handleKeyDown}
84
+ role={isZoomable ? "button" : role}
85
+ tabIndex={isZoomable ? (tabIndex ?? 0) : tabIndex}
86
+ aria-label={isZoomable ? (alt ? `Zoom image: ${alt}` : "Zoom image") : props["aria-label"]}
87
+ />
88
+ {mounted && open && resolvedSrc
89
+ ? createPortal(
90
+ <div className="velu-image-lightbox" role="dialog" aria-modal="true" onClick={() => setOpen(false)}>
91
+ <button
92
+ type="button"
93
+ className="velu-image-lightbox-close"
94
+ aria-label="Close image zoom"
95
+ onClick={() => setOpen(false)}
96
+ >
97
+ ×
98
+ </button>
99
+ <img
100
+ src={resolvedSrc}
101
+ alt={alt ?? ""}
102
+ className="velu-image-lightbox-img"
103
+ onClick={(event) => event.stopPropagation()}
104
+ />
105
+ </div>,
106
+ document.body,
107
+ )
108
+ : null}
109
+ </>
110
+ );
111
+ }
@@ -0,0 +1,154 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useState } from 'react';
4
+
5
+ type PlaygroundDisplayMode = 'interactive' | 'simple' | 'none';
6
+ type AuthMethod = 'bearer' | 'basic' | 'key' | 'none';
7
+
8
+ interface VeluManualApiPlaygroundProps {
9
+ method: string;
10
+ url: string;
11
+ display?: PlaygroundDisplayMode;
12
+ authMethod?: AuthMethod;
13
+ authName?: string;
14
+ className?: string;
15
+ }
16
+
17
+ interface RequestResult {
18
+ status: number;
19
+ statusText: string;
20
+ body: string;
21
+ }
22
+
23
+ export function VeluManualApiPlayground({
24
+ method,
25
+ url,
26
+ display = 'interactive',
27
+ authMethod = 'none',
28
+ authName = 'x-api-key',
29
+ className,
30
+ }: VeluManualApiPlaygroundProps) {
31
+ const normalizedMethod = String(method || 'GET').toUpperCase();
32
+ const [isLoading, setIsLoading] = useState(false);
33
+ const [error, setError] = useState<string | null>(null);
34
+ const [result, setResult] = useState<RequestResult | null>(null);
35
+
36
+ const [bearerToken, setBearerToken] = useState('');
37
+ const [basicUser, setBasicUser] = useState('');
38
+ const [basicPass, setBasicPass] = useState('');
39
+ const [apiKey, setApiKey] = useState('');
40
+
41
+ const headers = useMemo(() => {
42
+ const nextHeaders = new Headers();
43
+
44
+ if (authMethod === 'bearer' && bearerToken) {
45
+ nextHeaders.set('Authorization', `Bearer ${bearerToken}`);
46
+ }
47
+
48
+ if (authMethod === 'basic' && (basicUser || basicPass)) {
49
+ const encoded = window.btoa(`${basicUser}:${basicPass}`);
50
+ nextHeaders.set('Authorization', `Basic ${encoded}`);
51
+ }
52
+
53
+ if (authMethod === 'key' && apiKey) {
54
+ nextHeaders.set(authName || 'x-api-key', apiKey);
55
+ }
56
+
57
+ return nextHeaders;
58
+ }, [apiKey, authMethod, authName, basicPass, basicUser, bearerToken]);
59
+
60
+ async function sendRequest() {
61
+ setIsLoading(true);
62
+ setError(null);
63
+ setResult(null);
64
+
65
+ try {
66
+ const response = await fetch(url, {
67
+ method: normalizedMethod,
68
+ headers,
69
+ });
70
+
71
+ const text = await response.text();
72
+ let formatted = text;
73
+ try {
74
+ const parsed = JSON.parse(text);
75
+ formatted = JSON.stringify(parsed, null, 2);
76
+ } catch {
77
+ // Keep raw body text when response is not JSON.
78
+ }
79
+
80
+ setResult({
81
+ status: response.status,
82
+ statusText: response.statusText,
83
+ body: formatted,
84
+ });
85
+ } catch (err) {
86
+ const message = err instanceof Error ? err.message : 'Request failed';
87
+ setError(message);
88
+ } finally {
89
+ setIsLoading(false);
90
+ }
91
+ }
92
+
93
+ if (display === 'none') return null;
94
+
95
+ if (display === 'simple') {
96
+ return (
97
+ <section className={['velu-manual-api', 'velu-manual-api-simple', className].filter(Boolean).join(' ')}>
98
+ <code>{normalizedMethod} {url}</code>
99
+ </section>
100
+ );
101
+ }
102
+
103
+ return (
104
+ <section className={['velu-manual-api', className].filter(Boolean).join(' ')}>
105
+ <div className="velu-manual-api-head">
106
+ <span className="velu-manual-api-method">{normalizedMethod}</span>
107
+ <code className="velu-manual-api-url">{url}</code>
108
+ <button type="button" className="velu-manual-api-send" onClick={sendRequest} disabled={isLoading}>
109
+ {isLoading ? 'Sending…' : 'Send'}
110
+ </button>
111
+ </div>
112
+
113
+ {authMethod === 'bearer' ? (
114
+ <label className="velu-manual-api-auth">
115
+ <span>Bearer token</span>
116
+ <input type="password" value={bearerToken} onChange={(event) => setBearerToken(event.target.value)} placeholder="Enter token" />
117
+ </label>
118
+ ) : null}
119
+
120
+ {authMethod === 'basic' ? (
121
+ <div className="velu-manual-api-auth-grid">
122
+ <label className="velu-manual-api-auth">
123
+ <span>Username</span>
124
+ <input type="text" value={basicUser} onChange={(event) => setBasicUser(event.target.value)} placeholder="Username" />
125
+ </label>
126
+ <label className="velu-manual-api-auth">
127
+ <span>Password</span>
128
+ <input type="password" value={basicPass} onChange={(event) => setBasicPass(event.target.value)} placeholder="Password" />
129
+ </label>
130
+ </div>
131
+ ) : null}
132
+
133
+ {authMethod === 'key' ? (
134
+ <label className="velu-manual-api-auth">
135
+ <span>{authName || 'x-api-key'}</span>
136
+ <input type="password" value={apiKey} onChange={(event) => setApiKey(event.target.value)} placeholder="Enter API key" />
137
+ </label>
138
+ ) : null}
139
+
140
+ {error ? <p className="velu-manual-api-error">{error}</p> : null}
141
+
142
+ {result ? (
143
+ <div className="velu-manual-api-result">
144
+ <div className="velu-manual-api-status">
145
+ {result.status} {result.statusText}
146
+ </div>
147
+ <pre>
148
+ <code>{result.body || '(empty response)'}</code>
149
+ </pre>
150
+ </div>
151
+ ) : null}
152
+ </section>
153
+ );
154
+ }
@@ -0,0 +1,142 @@
1
+ "use client";
2
+
3
+ import mermaid from 'mermaid';
4
+ import { useEffect, useMemo, useRef, useState } from 'react';
5
+
6
+ type MermaidPlacement = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
7
+
8
+ let mermaidInitialized = false;
9
+
10
+ function ensureMermaid() {
11
+ if (mermaidInitialized) return;
12
+ mermaid.initialize({
13
+ startOnLoad: false,
14
+ securityLevel: 'loose',
15
+ theme: 'base',
16
+ suppressErrorRendering: true,
17
+ });
18
+ mermaidInitialized = true;
19
+ }
20
+
21
+ function buildSvgThemeCss() {
22
+ return [
23
+ ':root{--m-fg:#0f172a;--m-bg:#ffffff;--m-border:#cbd5e1;--m-edge-bg:#e2e8f0;}',
24
+ ':root[data-theme="dark"],.dark{--m-fg:#e5e7eb;--m-bg:#0b1220;--m-border:#334155;--m-edge-bg:#1f2937;}',
25
+ '.velu-mermaid-svg svg{font-family:inherit!important;}',
26
+ '.velu-mermaid-svg .label,.velu-mermaid-svg .label text,.velu-mermaid-svg .nodeLabel,.velu-mermaid-svg .nodeLabel p,.velu-mermaid-svg .edgeLabel,.velu-mermaid-svg .edgeLabel text,.velu-mermaid-svg .cluster-label text,.velu-mermaid-svg text{color:var(--m-fg)!important;fill:var(--m-fg)!important;}',
27
+ '.velu-mermaid-svg .node rect,.velu-mermaid-svg .node circle,.velu-mermaid-svg .node ellipse,.velu-mermaid-svg .node polygon,.velu-mermaid-svg .node path{stroke:var(--m-border)!important;fill:color-mix(in oklab,var(--m-bg) 92%,var(--m-fg) 8%)!important;}',
28
+ '.velu-mermaid-svg .edgePath .path,.velu-mermaid-svg .flowchart-link{stroke:var(--m-fg)!important;stroke-opacity:.95!important;}',
29
+ '.velu-mermaid-svg .edgeLabel rect,.velu-mermaid-svg .labelBkg{fill:var(--m-edge-bg)!important;stroke:var(--m-border)!important;opacity:1!important;}',
30
+ '.velu-mermaid-svg g.edgeLabel,.velu-mermaid-svg .edgeLabel{opacity:1!important;}',
31
+ '.velu-mermaid-svg .edgeLabel text{fill:var(--m-fg)!important;}',
32
+ '.velu-mermaid-svg .edgeLabel .label,.velu-mermaid-svg .edgeLabel .label *{color:var(--m-fg)!important;background:transparent!important;padding:0!important;border:0!important;box-shadow:none!important;}',
33
+ '.velu-mermaid-svg .edgeLabel foreignObject,.velu-mermaid-svg .edgeLabel foreignObject *{overflow:visible!important;}',
34
+ '.velu-mermaid-svg .cluster rect{fill:color-mix(in oklab,var(--m-bg) 88%,var(--m-fg) 12%)!important;stroke:var(--m-border)!important;}',
35
+ '.velu-mermaid-svg .marker,.velu-mermaid-svg marker path{fill:var(--m-fg)!important;stroke:var(--m-fg)!important;}',
36
+ ].join('');
37
+ }
38
+
39
+ export function VeluMermaid({
40
+ chart,
41
+ children,
42
+ className,
43
+ actions,
44
+ placement = 'bottom-right',
45
+ }: {
46
+ chart?: string;
47
+ children?: unknown;
48
+ className?: string;
49
+ actions?: boolean;
50
+ placement?: MermaidPlacement;
51
+ }) {
52
+ const source = useMemo(() => {
53
+ if (typeof chart === 'string' && chart.trim()) return chart;
54
+ if (typeof children === 'string' && children.trim()) return children;
55
+ return '';
56
+ }, [chart, children]);
57
+
58
+ const [svg, setSvg] = useState<string>('');
59
+ const [error, setError] = useState<string>('');
60
+ const [scale, setScale] = useState<number>(1);
61
+ const [offset, setOffset] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
62
+ const [autoActions, setAutoActions] = useState<boolean>(false);
63
+ const hostRef = useRef<HTMLDivElement | null>(null);
64
+ const uid = useMemo(() => `velu-mermaid-${Math.random().toString(36).slice(2, 10)}`, []);
65
+
66
+ useEffect(() => {
67
+ let cancelled = false;
68
+
69
+ async function run() {
70
+ if (!source) {
71
+ setSvg('');
72
+ setError('');
73
+ return;
74
+ }
75
+
76
+ try {
77
+ ensureMermaid();
78
+ const { svg: rendered } = await mermaid.render(uid, source);
79
+ if (cancelled) return;
80
+ setSvg(rendered);
81
+ setError('');
82
+ } catch {
83
+ if (cancelled) return;
84
+ setSvg('');
85
+ setError('Failed to render Mermaid diagram.');
86
+ }
87
+ }
88
+
89
+ run();
90
+ return () => {
91
+ cancelled = true;
92
+ };
93
+ }, [source, uid]);
94
+
95
+ useEffect(() => {
96
+ if (!hostRef.current || !svg) {
97
+ setAutoActions(false);
98
+ return;
99
+ }
100
+ const svgEl = hostRef.current.querySelector('svg');
101
+ const h = svgEl?.getBoundingClientRect().height ?? 0;
102
+ setAutoActions(h > 220);
103
+ }, [svg]);
104
+
105
+ const showActions = Boolean(actions ?? autoActions);
106
+
107
+ const controlsClass = `velu-mermaid-controls velu-mermaid-controls-${placement}`;
108
+ const transform = `translate(${offset.x}px, ${offset.y}px) scale(${scale})`;
109
+
110
+ return (
111
+ <div className={['velu-mermaid', className].filter(Boolean).join(' ')}>
112
+ <div
113
+ className="velu-mermaid-stage"
114
+ ref={hostRef}
115
+ style={{ transform }}
116
+ >
117
+ {error ? (
118
+ <pre className="velu-mermaid-error"><code>{source}</code></pre>
119
+ ) : (
120
+ <div
121
+ className="velu-mermaid-svg"
122
+ dangerouslySetInnerHTML={{ __html: svg }}
123
+ />
124
+ )}
125
+ </div>
126
+
127
+ <style dangerouslySetInnerHTML={{ __html: buildSvgThemeCss() }} />
128
+
129
+ {showActions ? (
130
+ <div className={controlsClass}>
131
+ <button className="velu-mermaid-btn-up" type="button" onClick={() => setOffset((p) => ({ ...p, y: p.y - 18 }))} aria-label="Pan up">↑</button>
132
+ <button className="velu-mermaid-btn-zoom-in" type="button" onClick={() => setScale((v) => Math.min(2.4, +(v + 0.1).toFixed(2)))} aria-label="Zoom in">+</button>
133
+ <button className="velu-mermaid-btn-left" type="button" onClick={() => setOffset((p) => ({ ...p, x: p.x - 18 }))} aria-label="Pan left">←</button>
134
+ <button className="velu-mermaid-btn-reset" type="button" onClick={() => { setScale(1); setOffset({ x: 0, y: 0 }); }} aria-label="Reset">↻</button>
135
+ <button className="velu-mermaid-btn-right" type="button" onClick={() => setOffset((p) => ({ ...p, x: p.x + 18 }))} aria-label="Pan right">→</button>
136
+ <button className="velu-mermaid-btn-down" type="button" onClick={() => setOffset((p) => ({ ...p, y: p.y + 18 }))} aria-label="Pan down">↓</button>
137
+ <button className="velu-mermaid-btn-zoom-out" type="button" onClick={() => setScale((v) => Math.max(0.6, +(v - 0.1).toFixed(2)))} aria-label="Zoom out">−</button>
138
+ </div>
139
+ ) : null}
140
+ </div>
141
+ );
142
+ }