veslx 0.0.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 (83) hide show
  1. package/README.md +3 -0
  2. package/bin/lib/import-config.ts +13 -0
  3. package/bin/lib/init.ts +31 -0
  4. package/bin/lib/serve.ts +35 -0
  5. package/bin/lib/start.ts +40 -0
  6. package/bin/lib/stop.ts +24 -0
  7. package/bin/vesl.ts +41 -0
  8. package/components.json +20 -0
  9. package/eslint.config.js +23 -0
  10. package/index.html +17 -0
  11. package/package.json +89 -0
  12. package/plugin/README.md +21 -0
  13. package/plugin/package.json +26 -0
  14. package/plugin/src/cli.ts +30 -0
  15. package/plugin/src/client.tsx +224 -0
  16. package/plugin/src/lib.ts +268 -0
  17. package/plugin/src/plugin.ts +109 -0
  18. package/postcss.config.js +5 -0
  19. package/public/logo_dark.png +0 -0
  20. package/public/logo_light.png +0 -0
  21. package/src/App.tsx +21 -0
  22. package/src/components/front-matter.tsx +53 -0
  23. package/src/components/gallery/components/figure-caption.tsx +15 -0
  24. package/src/components/gallery/components/figure-header.tsx +20 -0
  25. package/src/components/gallery/components/lightbox.tsx +106 -0
  26. package/src/components/gallery/components/loading-image.tsx +48 -0
  27. package/src/components/gallery/hooks/use-gallery-images.ts +103 -0
  28. package/src/components/gallery/hooks/use-lightbox.ts +40 -0
  29. package/src/components/gallery/index.tsx +134 -0
  30. package/src/components/gallery/lib/render-math-in-text.tsx +47 -0
  31. package/src/components/header.tsx +68 -0
  32. package/src/components/index.ts +5 -0
  33. package/src/components/loading.tsx +16 -0
  34. package/src/components/mdx-components.tsx +163 -0
  35. package/src/components/mode-toggle.tsx +44 -0
  36. package/src/components/page-error.tsx +59 -0
  37. package/src/components/parameter-badge.tsx +78 -0
  38. package/src/components/parameter-table.tsx +420 -0
  39. package/src/components/post-list.tsx +148 -0
  40. package/src/components/running-bar.tsx +21 -0
  41. package/src/components/runtime-mdx.tsx +82 -0
  42. package/src/components/slide.tsx +11 -0
  43. package/src/components/theme-provider.tsx +6 -0
  44. package/src/components/ui/badge.tsx +36 -0
  45. package/src/components/ui/breadcrumb.tsx +115 -0
  46. package/src/components/ui/button.tsx +56 -0
  47. package/src/components/ui/card.tsx +79 -0
  48. package/src/components/ui/carousel.tsx +260 -0
  49. package/src/components/ui/dropdown-menu.tsx +198 -0
  50. package/src/components/ui/input.tsx +22 -0
  51. package/src/components/ui/kbd.tsx +22 -0
  52. package/src/components/ui/select.tsx +158 -0
  53. package/src/components/ui/separator.tsx +29 -0
  54. package/src/components/ui/shadcn-io/code-block/index.tsx +620 -0
  55. package/src/components/ui/shadcn-io/code-block/server.tsx +63 -0
  56. package/src/components/ui/sheet.tsx +140 -0
  57. package/src/components/ui/sidebar.tsx +771 -0
  58. package/src/components/ui/skeleton.tsx +15 -0
  59. package/src/components/ui/spinner.tsx +16 -0
  60. package/src/components/ui/tooltip.tsx +28 -0
  61. package/src/components/welcome.tsx +21 -0
  62. package/src/hooks/use-key-bindings.ts +72 -0
  63. package/src/hooks/use-mobile.tsx +19 -0
  64. package/src/index.css +279 -0
  65. package/src/lib/constants.ts +10 -0
  66. package/src/lib/format-date.tsx +6 -0
  67. package/src/lib/format-file-size.ts +10 -0
  68. package/src/lib/parameter-utils.ts +134 -0
  69. package/src/lib/utils.ts +6 -0
  70. package/src/main.tsx +10 -0
  71. package/src/pages/home.tsx +39 -0
  72. package/src/pages/post.tsx +65 -0
  73. package/src/pages/slides.tsx +173 -0
  74. package/tailwind.config.js +136 -0
  75. package/test-content/.vesl.json +49 -0
  76. package/test-content/README.md +33 -0
  77. package/test-content/test-post/README.mdx +7 -0
  78. package/test-content/test-slides/SLIDES.mdx +8 -0
  79. package/tsconfig.app.json +32 -0
  80. package/tsconfig.json +15 -0
  81. package/tsconfig.node.json +25 -0
  82. package/vesl.config.ts +4 -0
  83. package/vite.config.ts +54 -0
@@ -0,0 +1,103 @@
1
+ import { useMemo } from "react";
2
+ import { useTheme } from "next-themes";
3
+ import { useDirectory } from "../../../../plugin/src/client";
4
+ import { FileEntry } from "../../../../plugin/src/lib";
5
+ import { minimatch } from "minimatch";
6
+
7
+ function sortPathsNumerically(paths: string[]): void {
8
+ paths.sort((a, b) => {
9
+ const nums = (s: string) => (s.match(/\d+/g) || []).map(Number);
10
+ const na = nums(a);
11
+ const nb = nums(b);
12
+ const len = Math.max(na.length, nb.length);
13
+ for (let i = 0; i < len; i++) {
14
+ const diff = (na[i] ?? 0) - (nb[i] ?? 0);
15
+ if (diff !== 0) return diff;
16
+ }
17
+ return a.localeCompare(b);
18
+ });
19
+ }
20
+
21
+ function filterPathsByTheme(paths: string[], theme: string | undefined): string[] {
22
+ const pathGroups = new Map<string, { light?: string; dark?: string; original?: string }>();
23
+
24
+ paths.forEach(path => {
25
+ if (path.endsWith('_light.png')) {
26
+ const baseName = path.replace('_light.png', '');
27
+ const group = pathGroups.get(baseName) || {};
28
+ group.light = path;
29
+ pathGroups.set(baseName, group);
30
+ } else if (path.endsWith('_dark.png')) {
31
+ const baseName = path.replace('_dark.png', '');
32
+ const group = pathGroups.get(baseName) || {};
33
+ group.dark = path;
34
+ pathGroups.set(baseName, group);
35
+ } else {
36
+ pathGroups.set(path, { original: path });
37
+ }
38
+ });
39
+
40
+ const filtered: string[] = [];
41
+ pathGroups.forEach((group, baseName) => {
42
+ if (group.original) {
43
+ filtered.push(group.original);
44
+ } else {
45
+ const isDark = theme === 'dark';
46
+ const preferredPath = isDark ? group.dark : group.light;
47
+ const fallbackPath = isDark ? group.light : group.dark;
48
+ filtered.push(preferredPath || fallbackPath || baseName);
49
+ }
50
+ });
51
+
52
+ return filtered;
53
+ }
54
+
55
+
56
+ export function useGalleryImages({
57
+ path,
58
+ globs = null,
59
+ limit,
60
+ page = 0,
61
+ }: {
62
+ path?: string;
63
+ globs?: string[] | null;
64
+ limit?: number | null;
65
+ page?: number;
66
+ }) {
67
+ const { resolvedTheme } = useTheme();
68
+
69
+ let resolvedPath = path;
70
+
71
+ const { directory } = useDirectory(resolvedPath);
72
+
73
+ const paths = useMemo(() => {
74
+ if (!directory) return [];
75
+
76
+ const imageChildren = directory.children.filter((child): child is FileEntry => {
77
+ return !!child.name.match(/\.(png|jpeg|gif|svg|webp)$/i) && child.type === "file";
78
+ });
79
+
80
+ let imagePaths = imageChildren.map(child => child.path);
81
+
82
+ if (globs && globs.length > 0) {
83
+ imagePaths = imagePaths.filter(p => {
84
+ return globs.some(glob => minimatch(p.split('/').pop() || '', glob));
85
+ });
86
+ }
87
+
88
+ sortPathsNumerically(imagePaths);
89
+ let filtered = filterPathsByTheme(imagePaths, resolvedTheme);
90
+
91
+ if (limit) {
92
+ filtered = filtered.slice(page * limit, (page + 1) * limit);
93
+ }
94
+
95
+ return filtered;
96
+ }, [directory, globs, resolvedTheme, limit, page]);
97
+
98
+ return {
99
+ paths,
100
+ isLoading: !directory,
101
+ isEmpty: directory !== undefined && paths.length === 0,
102
+ };
103
+ }
@@ -0,0 +1,40 @@
1
+ import { useKeyBindings } from "@/hooks/use-key-bindings";
2
+ import { useCallback, useState } from "react";
3
+
4
+ export function useLightbox(totalImages: number) {
5
+ const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
6
+
7
+ const open = useCallback((index: number) => {
8
+ setSelectedIndex(index);
9
+ }, []);
10
+
11
+ const close = useCallback(() => {
12
+ setSelectedIndex(null);
13
+ }, []);
14
+
15
+ const goToPrevious = useCallback(() => {
16
+ setSelectedIndex(prev => prev !== null && prev > 0 ? prev - 1 : prev);
17
+ }, []);
18
+
19
+ const goToNext = useCallback(() => {
20
+ setSelectedIndex(prev => prev !== null && prev < totalImages - 1 ? prev + 1 : prev);
21
+ }, [totalImages]);
22
+
23
+ useKeyBindings(
24
+ [
25
+ { key: "Escape", action: close },
26
+ { key: "ArrowLeft", action: goToPrevious },
27
+ { key: "ArrowRight", action: goToNext },
28
+ ],
29
+ { enabled: () => selectedIndex !== null }
30
+ );
31
+
32
+ return {
33
+ selectedIndex,
34
+ open,
35
+ close,
36
+ goToPrevious,
37
+ goToNext,
38
+ isOpen: selectedIndex !== null,
39
+ };
40
+ }
@@ -0,0 +1,134 @@
1
+ import { useMemo } from "react";
2
+ import { Image } from "lucide-react";
3
+ import { Lightbox, LightboxImage } from "@/components/gallery/components/lightbox";
4
+ import { useGalleryImages } from "./hooks/use-gallery-images";
5
+ import { useLightbox } from "./hooks/use-lightbox";
6
+ import { LoadingImage } from "./components/loading-image";
7
+ import { FigureHeader } from "./components/figure-header";
8
+ import { FigureCaption } from "./components/figure-caption";
9
+ import {
10
+ Carousel,
11
+ CarouselContent,
12
+ CarouselItem,
13
+ CarouselNext,
14
+ CarouselPrevious,
15
+ } from "@/components/ui/carousel"
16
+
17
+ function getImageLabel(path: string): string {
18
+ const filename = path.split('/').pop() || path;
19
+ return filename
20
+ .replace(/\.(png|jpg|jpeg|gif|svg|webp)$/i, '')
21
+ .replace(/[_-]/g, ' ')
22
+ .replace(/\s+/g, ' ')
23
+ .trim();
24
+ }
25
+
26
+ function getImageUrl(path: string): string {
27
+ return `/raw/${path}`;
28
+ }
29
+
30
+ export default function Gallery({
31
+ path,
32
+ globs = null,
33
+ caption,
34
+ captionLabel,
35
+ title,
36
+ subtitle,
37
+ limit = null,
38
+ page = 0,
39
+ }: {
40
+ path?: string;
41
+ globs?: string[] | null;
42
+ caption?: string;
43
+ captionLabel?: string;
44
+ title?: string;
45
+ subtitle?: string;
46
+ limit?: number | null;
47
+ page?: number;
48
+ }) {
49
+ const { paths, isLoading, isEmpty } = useGalleryImages({
50
+ path,
51
+ globs,
52
+ limit,
53
+ page: page,
54
+ });
55
+
56
+ const lightbox = useLightbox(paths.length);
57
+
58
+ const images: LightboxImage[] = useMemo(() =>
59
+ paths.map(p => ({ src: getImageUrl(p), label: getImageLabel(p) })),
60
+ [paths]
61
+ );
62
+
63
+ if (isLoading) {
64
+ return (
65
+ <div className="not-prose py-4 md:py-6">
66
+ <div className="grid grid-cols-3 gap-2 sm:gap-4 max-w-[var(--gallery-width)] mx-auto">
67
+ {[...Array(3)].map((_, i) => (
68
+ <div
69
+ key={i}
70
+ className="aspect-[4/3] bg-muted/30 animate-pulse"
71
+ style={{ animationDelay: `${i * 100}ms` }}
72
+ />
73
+ ))}
74
+ </div>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ if (isEmpty) {
80
+ return (
81
+ <div className="not-prose py-16 text-center">
82
+ <div className="inline-flex items-center gap-3 text-muted-foreground/60">
83
+ <Image className="h-4 w-4" />
84
+ <span className="font-mono text-sm tracking-wide">no image(s) found</span>
85
+ </div>
86
+ </div>
87
+ );
88
+ }
89
+
90
+ return (
91
+ <>
92
+ <div className="rounded-lg not-prose flex flex-col gap-0 relative p-4 -mx-[calc((var(--gallery-width)-var(--content-width))/2+var(--page-padding))]">
93
+ <FigureHeader title={title} subtitle={subtitle} />
94
+
95
+ <Carousel>
96
+ <CarouselContent>
97
+ {images.map((img, index) => (
98
+ <CarouselItem
99
+ key={index}
100
+ className="mx-auto md:basis-1/2 lg:basis-1/3 cursor-pointer transition-transform duration-700 ease-out-expo hover:scale-[1.02]"
101
+ // wrapperClassName="h-full"
102
+ onClick={() => lightbox.open(index)}
103
+ >
104
+ <LoadingImage
105
+ src={img.src}
106
+ alt={img.label}
107
+ />
108
+ </CarouselItem>
109
+ ))}
110
+ </CarouselContent>
111
+
112
+ {images.length > 3 && (
113
+ <>
114
+ <CarouselPrevious />
115
+ <CarouselNext />
116
+ </>
117
+ )}
118
+ </Carousel>
119
+
120
+ <FigureCaption caption={caption} label={captionLabel} />
121
+ </div>
122
+
123
+ {lightbox.isOpen && lightbox.selectedIndex !== null && (
124
+ <Lightbox
125
+ images={images}
126
+ selectedIndex={lightbox.selectedIndex}
127
+ onClose={lightbox.close}
128
+ onPrevious={lightbox.goToPrevious}
129
+ onNext={lightbox.goToNext}
130
+ />
131
+ )}
132
+ </>
133
+ );
134
+ }
@@ -0,0 +1,47 @@
1
+ import { ReactNode } from "react";
2
+ import katex from "katex";
3
+
4
+ export function renderMathInText(text: string): ReactNode {
5
+ // Match $...$ for inline math and $$...$$ for display math
6
+ const parts: ReactNode[] = [];
7
+ let lastIndex = 0;
8
+ // Match display math ($$...$$) first, then inline math ($...$)
9
+ const regex = /\$\$([^$]+)\$\$|\$([^$]+)\$/g;
10
+ let match;
11
+ let key = 0;
12
+
13
+ while ((match = regex.exec(text)) !== null) {
14
+ // Add text before this match
15
+ if (match.index > lastIndex) {
16
+ parts.push(text.slice(lastIndex, match.index));
17
+ }
18
+
19
+ const isDisplay = match[1] !== undefined;
20
+ const mathContent = match[1] || match[2];
21
+
22
+ try {
23
+ const html = katex.renderToString(mathContent, {
24
+ displayMode: isDisplay,
25
+ throwOnError: false,
26
+ });
27
+ parts.push(
28
+ <span
29
+ key={key++}
30
+ dangerouslySetInnerHTML={{ __html: html }}
31
+ />
32
+ );
33
+ } catch {
34
+ // If KaTeX fails, just show the original text
35
+ parts.push(match[0]);
36
+ }
37
+
38
+ lastIndex = match.index + match[0].length;
39
+ }
40
+
41
+ // Add remaining text
42
+ if (lastIndex < text.length) {
43
+ parts.push(text.slice(lastIndex));
44
+ }
45
+
46
+ return parts.length === 1 && typeof parts[0] === 'string' ? parts[0] : <>{parts}</>;
47
+ }
@@ -0,0 +1,68 @@
1
+ import { Link } from "react-router-dom";
2
+ import { ModeToggle } from "./mode-toggle";
3
+ import { SiGithub } from "@icons-pack/react-simple-icons";
4
+ import { ChevronUp, ChevronDown } from "lucide-react";
5
+
6
+ interface HeaderProps {
7
+ slideControls?: {
8
+ current: number;
9
+ total: number;
10
+ onPrevious: () => void;
11
+ onNext: () => void;
12
+ };
13
+ }
14
+
15
+ export function Header({ slideControls }: HeaderProps = {}) {
16
+ return (
17
+ <header className="print:hidden fixed top-0 left-0 right-0 z-40">
18
+ <div className="mx-auto w-full px-[var(--page-padding)] flex items-center gap-8 py-4">
19
+ <nav className="flex items-center gap-1">
20
+ <Link
21
+ to="/"
22
+ className="rounded-lg font-mono py-1.5 text-sm font-medium text-muted-foreground hover:underline"
23
+ >
24
+ pl
25
+ </Link>
26
+ </nav>
27
+
28
+ <div className="flex-1" />
29
+
30
+ {/* Slide navigation controls */}
31
+ {slideControls && (
32
+ <nav className="flex items-center gap-1">
33
+ <button
34
+ onClick={slideControls.onPrevious}
35
+ className="p-1.5 text-muted-foreground/70 hover:text-foreground transition-colors duration-200"
36
+ title="Previous slide (↑)"
37
+ >
38
+ <ChevronUp className="h-4 w-4" />
39
+ </button>
40
+ <span className="font-mono text-xs text-muted-foreground/70 tabular-nums min-w-[3ch] text-center">
41
+ {slideControls.current + 1}/{slideControls.total}
42
+ </span>
43
+ <button
44
+ onClick={slideControls.onNext}
45
+ className="p-1.5 text-muted-foreground/70 hover:text-foreground transition-colors duration-200"
46
+ title="Next slide (↓)"
47
+ >
48
+ <ChevronDown className="h-4 w-4" />
49
+ </button>
50
+ </nav>
51
+ )}
52
+
53
+ {/* Navigation */}
54
+ <nav className="flex items-center gap-2">
55
+ <Link
56
+ to="https://github.com/eoinmurray/pinglab"
57
+ target="_blank"
58
+ className="text-muted-foreground/70 hover:text-foreground transition-colors duration-300"
59
+ aria-label="GitHub"
60
+ >
61
+ <SiGithub className="h-4 w-4" />
62
+ </Link>
63
+ <ModeToggle />
64
+ </nav>
65
+ </div>
66
+ </header>
67
+ );
68
+ }
@@ -0,0 +1,5 @@
1
+
2
+ export { default as Gallery } from './gallery'
3
+ export { ParameterTable } from './parameter-table'
4
+ export { ParameterBadge } from './parameter-badge'
5
+ export { Slide } from './slide'
@@ -0,0 +1,16 @@
1
+ export default function Loading() {
2
+ return (
3
+ <main className="min-h-screen flex items-center justify-center bg-background">
4
+ <div className="flex flex-col items-center gap-6 animate-fade-in">
5
+ <div className="flex items-center gap-3">
6
+ <div className="w-1.5 h-1.5 bg-primary/60 rounded-full animate-pulse" />
7
+ <div className="w-1.5 h-1.5 bg-primary/40 rounded-full animate-pulse" style={{ animationDelay: '150ms' }} />
8
+ <div className="w-1.5 h-1.5 bg-primary/20 rounded-full animate-pulse" style={{ animationDelay: '300ms' }} />
9
+ </div>
10
+ <p className="font-mono text-xs text-muted-foreground/50 tracking-widest uppercase">
11
+ loading
12
+ </p>
13
+ </div>
14
+ </main>
15
+ )
16
+ }
@@ -0,0 +1,163 @@
1
+
2
+ import Gallery from '@/components/gallery'
3
+ import { ParameterTable } from '@/components/parameter-table'
4
+ import { ParameterBadge } from '@/components/parameter-badge'
5
+
6
+ function generateId(children: unknown): string {
7
+ return children
8
+ ?.toString()
9
+ .toLowerCase()
10
+ .replace(/[^a-z0-9\s-]/g, '')
11
+ .replace(/\s+/g, '-') ?? ''
12
+ }
13
+
14
+ // Shared MDX components - lab notebook / coder aesthetic
15
+ export const mdxComponents = {
16
+ Gallery,
17
+
18
+ ParameterTable,
19
+
20
+ ParameterBadge,
21
+
22
+ // Headings - clean sans-serif
23
+ h1: (props: React.HTMLAttributes<HTMLHeadingElement>) => {
24
+ const id = generateId(props.children)
25
+ return (
26
+ <h1
27
+ id={id}
28
+ className="text-2xl font-semibold tracking-tight mt-12 mb-4 first:mt-0"
29
+ {...props}
30
+ />
31
+ )
32
+ },
33
+ h2: (props: React.HTMLAttributes<HTMLHeadingElement>) => {
34
+ const id = generateId(props.children)
35
+ return (
36
+ <h2
37
+ id={id}
38
+ className="text-xl font-semibold tracking-tight mt-10 mb-3 pb-2 border-b border-border"
39
+ {...props}
40
+ />
41
+ )
42
+ },
43
+ h3: (props: React.HTMLAttributes<HTMLHeadingElement>) => {
44
+ const id = generateId(props.children)
45
+ return (
46
+ <h3
47
+ id={id}
48
+ className="text-lg font-medium tracking-tight mt-8 mb-2"
49
+ {...props}
50
+ />
51
+ )
52
+ },
53
+ h4: (props: React.HTMLAttributes<HTMLHeadingElement>) => {
54
+ const id = generateId(props.children)
55
+ return (
56
+ <h4
57
+ id={id}
58
+ className="text-base font-medium mt-6 mb-2"
59
+ {...props}
60
+ />
61
+ )
62
+ },
63
+ h5: (props: React.HTMLAttributes<HTMLHeadingElement>) => {
64
+ const id = generateId(props.children)
65
+ return (
66
+ <h5
67
+ id={id}
68
+ className="text-sm font-medium mt-4 mb-1"
69
+ {...props}
70
+ />
71
+ )
72
+ },
73
+
74
+ // Code blocks - IDE/terminal style
75
+ pre: (props: React.HTMLAttributes<HTMLPreElement>) => (
76
+ <pre
77
+ className="not-prose w-full overflow-x-auto p-4 text-sm bg-muted/50 border border-border rounded-md font-mono my-6"
78
+ {...props}
79
+ />
80
+ ),
81
+ code: (props: React.HTMLAttributes<HTMLElement> & { className?: string }) => {
82
+ const isInline = !props.className?.includes('language-')
83
+ if (isInline) {
84
+ return (
85
+ <code
86
+ className="font-mono text-[0.85em] bg-muted px-1.5 py-0.5 rounded text-primary"
87
+ {...props}
88
+ />
89
+ )
90
+ }
91
+ return <code {...props} />
92
+ },
93
+
94
+ // Blockquote
95
+ blockquote: (props: React.HTMLAttributes<HTMLQuoteElement>) => (
96
+ <blockquote
97
+ className="border-l-2 border-primary pl-4 my-6 text-muted-foreground"
98
+ {...props}
99
+ />
100
+ ),
101
+
102
+ // Lists
103
+ ul: (props: React.HTMLAttributes<HTMLUListElement>) => (
104
+ <ul className="my-4 ml-6 list-disc marker:text-muted-foreground" {...props} />
105
+ ),
106
+ ol: (props: React.HTMLAttributes<HTMLOListElement>) => (
107
+ <ol className="my-4 ml-6 list-decimal marker:text-muted-foreground" {...props} />
108
+ ),
109
+ li: (props: React.HTMLAttributes<HTMLLIElement>) => (
110
+ <li className="mt-1.5" {...props} />
111
+ ),
112
+
113
+ // Links
114
+ a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
115
+ <a
116
+ className="text-primary hover:underline underline-offset-2"
117
+ {...props}
118
+ />
119
+ ),
120
+
121
+ // Tables
122
+ table: (props: React.TableHTMLAttributes<HTMLTableElement>) => (
123
+ <div className="not-prose my-6 overflow-x-auto border border-border rounded-md">
124
+ <table className="w-full text-sm border-collapse" {...props} />
125
+ </div>
126
+ ),
127
+ thead: (props: React.HTMLAttributes<HTMLTableSectionElement>) => (
128
+ <thead className="bg-muted/50" {...props} />
129
+ ),
130
+ tbody: (props: React.HTMLAttributes<HTMLTableSectionElement>) => (
131
+ <tbody {...props} />
132
+ ),
133
+ tr: (props: React.HTMLAttributes<HTMLTableRowElement>) => (
134
+ <tr className="border-b border-border last:border-b-0" {...props} />
135
+ ),
136
+ th: (props: React.ThHTMLAttributes<HTMLTableCellElement>) => (
137
+ <th
138
+ className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
139
+ {...props}
140
+ />
141
+ ),
142
+ td: (props: React.TdHTMLAttributes<HTMLTableCellElement>) => (
143
+ <td className="px-4 py-3 align-top" {...props} />
144
+ ),
145
+
146
+ // Horizontal rule
147
+ hr: (props: React.HTMLAttributes<HTMLHRElement>) => (
148
+ <hr className="my-8 border-t border-border" {...props} />
149
+ ),
150
+
151
+ // Paragraph
152
+ p: (props: React.HTMLAttributes<HTMLParagraphElement>) => (
153
+ <p className="leading-relaxed mb-4 last:mb-0" {...props} />
154
+ ),
155
+
156
+ // Strong/emphasis
157
+ strong: (props: React.HTMLAttributes<HTMLElement>) => (
158
+ <strong className="font-semibold" {...props} />
159
+ ),
160
+ em: (props: React.HTMLAttributes<HTMLElement>) => (
161
+ <em className="italic" {...props} />
162
+ ),
163
+ }
@@ -0,0 +1,44 @@
1
+ import { Moon, Sun, Monitor } from "lucide-react"
2
+ import { useTheme } from "next-themes"
3
+ import {
4
+ DropdownMenu,
5
+ DropdownMenuContent,
6
+ DropdownMenuRadioGroup,
7
+ DropdownMenuRadioItem,
8
+ DropdownMenuTrigger,
9
+ } from "@/components/ui/dropdown-menu"
10
+
11
+ export function ModeToggle() {
12
+ const { theme, setTheme } = useTheme()
13
+
14
+ return (
15
+ <DropdownMenu>
16
+ <DropdownMenuTrigger asChild>
17
+ <button
18
+ className="relative p-2 text-muted-foreground/70 hover:text-foreground transition-colors duration-300"
19
+ aria-label="Toggle theme"
20
+ >
21
+ <Sun className={`h-4 w-4 transition-all duration-300 ${theme === "light" ? "scale-100 rotate-0" : "scale-0 -rotate-90 absolute top-2 left-2"}`} />
22
+ <Moon className={`h-4 w-4 transition-all duration-300 ${theme === "dark" ? "scale-100 rotate-0" : "scale-0 rotate-90 absolute top-2 left-2"}`} />
23
+ <Monitor className={`h-4 w-4 transition-all duration-300 ${theme === "system" || !theme ? "scale-100" : "scale-0 absolute top-2 left-2"}`} />
24
+ </button>
25
+ </DropdownMenuTrigger>
26
+ <DropdownMenuContent align="end">
27
+ <DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
28
+ <DropdownMenuRadioItem value="light">
29
+ <Sun className="mr-2 h-4 w-4" />
30
+ Light
31
+ </DropdownMenuRadioItem>
32
+ <DropdownMenuRadioItem value="dark">
33
+ <Moon className="mr-2 h-4 w-4" />
34
+ Dark
35
+ </DropdownMenuRadioItem>
36
+ <DropdownMenuRadioItem value="system">
37
+ <Monitor className="mr-2 h-4 w-4" />
38
+ System
39
+ </DropdownMenuRadioItem>
40
+ </DropdownMenuRadioGroup>
41
+ </DropdownMenuContent>
42
+ </DropdownMenu>
43
+ )
44
+ }
@@ -0,0 +1,59 @@
1
+
2
+ import { DirectoryError } from "../../plugin/src/client";
3
+
4
+ export function ErrorDisplay({ error, path }: { error: DirectoryError; path: string }) {
5
+ const containerClass = "min-h-screen bg-background container mx-auto max-w-[var(--content-width)] py-24 px-[var(--page-padding)]";
6
+
7
+ switch (error.type) {
8
+ case 'config_not_found':
9
+ return (
10
+ <main className={containerClass}>
11
+ <div className="text-center space-y-4">
12
+ <h1 className="text-2xl font-semibold tracking-tight">Setup Required</h1>
13
+ <p className="text-muted-foreground">
14
+ Could not find <code className="font-mono text-sm bg-muted px-2 py-1">.veslx.json</code>
15
+ </p>
16
+ <p className="text-muted-foreground/70 text-sm">
17
+ Run the veslx build script to generate the directory index.
18
+ </p>
19
+ </div>
20
+ </main>
21
+ );
22
+
23
+ case 'path_not_found':
24
+ return (
25
+ <main className={containerClass}>
26
+ <div className="text-center space-y-4">
27
+ <h1 className="font-mono text-6xl tracking-tighter text-muted-foreground/30">404</h1>
28
+ <p className="text-lg text-foreground">Page not found</p>
29
+ <p className="text-muted-foreground text-sm">
30
+ <code className="font-mono bg-muted px-2 py-1">{path}</code>
31
+ </p>
32
+ </div>
33
+ </main>
34
+ );
35
+
36
+ case 'parse_error':
37
+ return (
38
+ <main className={containerClass}>
39
+ <div className="text-center space-y-4">
40
+ <h1 className="text-2xl font-semibold text-destructive">Configuration Error</h1>
41
+ <p className="text-muted-foreground">
42
+ Failed to parse <code className="font-mono text-sm bg-muted px-2 py-1">.veslx.json</code>
43
+ </p>
44
+ </div>
45
+ </main>
46
+ );
47
+
48
+ case 'fetch_error':
49
+ default:
50
+ return (
51
+ <main className={containerClass}>
52
+ <div className="text-center space-y-4">
53
+ <h1 className="text-2xl font-semibold text-destructive">Error</h1>
54
+ <p className="text-muted-foreground">{error.message}</p>
55
+ </div>
56
+ </main>
57
+ );
58
+ }
59
+ }