veslx 0.1.5 → 0.1.15

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 (97) hide show
  1. package/README.md +262 -55
  2. package/bin/lib/build.ts +65 -13
  3. package/bin/lib/import-config.ts +10 -9
  4. package/bin/lib/init.ts +21 -22
  5. package/bin/lib/serve.ts +66 -12
  6. package/bin/veslx.ts +2 -2
  7. package/dist/client/App.js +3 -9
  8. package/dist/client/App.js.map +1 -1
  9. package/dist/client/components/front-matter.js +11 -25
  10. package/dist/client/components/front-matter.js.map +1 -1
  11. package/dist/client/components/gallery/components/figure-caption.js +6 -4
  12. package/dist/client/components/gallery/components/figure-caption.js.map +1 -1
  13. package/dist/client/components/gallery/components/figure-header.js +3 -3
  14. package/dist/client/components/gallery/components/figure-header.js.map +1 -1
  15. package/dist/client/components/gallery/components/lightbox.js +13 -13
  16. package/dist/client/components/gallery/components/lightbox.js.map +1 -1
  17. package/dist/client/components/gallery/components/loading-image.js +11 -10
  18. package/dist/client/components/gallery/components/loading-image.js.map +1 -1
  19. package/dist/client/components/gallery/hooks/use-gallery-images.js +31 -7
  20. package/dist/client/components/gallery/hooks/use-gallery-images.js.map +1 -1
  21. package/dist/client/components/gallery/index.js +22 -15
  22. package/dist/client/components/gallery/index.js.map +1 -1
  23. package/dist/client/components/header.js +5 -3
  24. package/dist/client/components/header.js.map +1 -1
  25. package/dist/client/components/mdx-components.js +42 -8
  26. package/dist/client/components/mdx-components.js.map +1 -1
  27. package/dist/client/components/post-list.js +97 -90
  28. package/dist/client/components/post-list.js.map +1 -1
  29. package/dist/client/components/running-bar.js +1 -1
  30. package/dist/client/components/running-bar.js.map +1 -1
  31. package/dist/client/components/slide.js +18 -0
  32. package/dist/client/components/slide.js.map +1 -0
  33. package/dist/client/components/slides-renderer.js +7 -71
  34. package/dist/client/components/slides-renderer.js.map +1 -1
  35. package/dist/client/hooks/use-mdx-content.js +55 -9
  36. package/dist/client/hooks/use-mdx-content.js.map +1 -1
  37. package/dist/client/main.js +1 -0
  38. package/dist/client/main.js.map +1 -1
  39. package/dist/client/pages/content-router.js +19 -0
  40. package/dist/client/pages/content-router.js.map +1 -0
  41. package/dist/client/pages/home.js +11 -7
  42. package/dist/client/pages/home.js.map +1 -1
  43. package/dist/client/pages/post.js +8 -20
  44. package/dist/client/pages/post.js.map +1 -1
  45. package/dist/client/pages/slides.js +62 -86
  46. package/dist/client/pages/slides.js.map +1 -1
  47. package/dist/client/plugin/src/client.js +58 -96
  48. package/dist/client/plugin/src/client.js.map +1 -1
  49. package/dist/client/plugin/src/directory-tree.js +111 -0
  50. package/dist/client/plugin/src/directory-tree.js.map +1 -0
  51. package/index.html +1 -1
  52. package/package.json +34 -15
  53. package/plugin/src/client.tsx +64 -116
  54. package/plugin/src/directory-tree.ts +171 -0
  55. package/plugin/src/lib.ts +6 -249
  56. package/plugin/src/plugin.ts +93 -50
  57. package/plugin/src/remark-slides.ts +100 -0
  58. package/plugin/src/types.ts +22 -0
  59. package/src/App.tsx +3 -6
  60. package/src/components/front-matter.tsx +14 -29
  61. package/src/components/gallery/components/figure-caption.tsx +15 -7
  62. package/src/components/gallery/components/figure-header.tsx +3 -3
  63. package/src/components/gallery/components/lightbox.tsx +15 -13
  64. package/src/components/gallery/components/loading-image.tsx +15 -12
  65. package/src/components/gallery/hooks/use-gallery-images.ts +51 -10
  66. package/src/components/gallery/index.tsx +32 -26
  67. package/src/components/header.tsx +14 -9
  68. package/src/components/mdx-components.tsx +61 -8
  69. package/src/components/post-list.tsx +149 -115
  70. package/src/components/running-bar.tsx +1 -1
  71. package/src/components/slide.tsx +22 -5
  72. package/src/components/slides-renderer.tsx +7 -115
  73. package/src/components/welcome.tsx +11 -14
  74. package/src/hooks/use-mdx-content.ts +94 -9
  75. package/src/index.css +159 -0
  76. package/src/main.tsx +1 -0
  77. package/src/pages/content-router.tsx +27 -0
  78. package/src/pages/home.tsx +16 -2
  79. package/src/pages/post.tsx +10 -13
  80. package/src/pages/slides.tsx +75 -88
  81. package/src/vite-env.d.ts +7 -17
  82. package/vite.config.ts +25 -6
  83. package/dist/assets/README-NSyLDlyP.js +0 -7
  84. package/dist/assets/SLIDES-C12TOqNU.js +0 -10
  85. package/dist/assets/_virtual_content-modules-DK3Yb9K2.js +0 -2
  86. package/dist/assets/index-BUMwRZ7d.js +0 -468
  87. package/dist/assets/index-C8sJQuOZ.js +0 -1
  88. package/dist/assets/index-PspMxLnH.css +0 -1
  89. package/dist/index.html +0 -18
  90. package/dist/logo_dark.png +0 -0
  91. package/dist/logo_light.png +0 -0
  92. package/dist/raw/.veslx.json +0 -61
  93. package/dist/raw/README.md +0 -33
  94. package/dist/raw/test-post/Chart.tsx +0 -16
  95. package/dist/raw/test-post/README.mdx +0 -21
  96. package/dist/raw/test-slides/Counter.tsx +0 -25
  97. package/dist/raw/test-slides/SLIDES.mdx +0 -27
@@ -1,9 +1,26 @@
1
1
  import { useMemo } from "react";
2
2
  import { useTheme } from "next-themes";
3
+ import { useParams } from "react-router-dom";
3
4
  import { useDirectory } from "../../../../plugin/src/client";
4
- import { FileEntry } from "../../../../plugin/src/lib";
5
+ import { FileEntry, DirectoryEntry } from "../../../../plugin/src/lib";
5
6
  import { minimatch } from "minimatch";
6
7
 
8
+ // Recursively collect all image files from a directory tree
9
+ function collectAllImages(entry: DirectoryEntry | FileEntry): FileEntry[] {
10
+ if (entry.type === "file") {
11
+ if (entry.name.match(/\.(png|jpg|jpeg|gif|svg|webp)$/i)) {
12
+ return [entry];
13
+ }
14
+ return [];
15
+ }
16
+ // It's a directory - recurse into children
17
+ const images: FileEntry[] = [];
18
+ for (const child of entry.children || []) {
19
+ images.push(...collectAllImages(child));
20
+ }
21
+ return images;
22
+ }
23
+
7
24
  function sortPathsNumerically(paths: string[]): void {
8
25
  paths.sort((a, b) => {
9
26
  const nums = (s: string) => (s.match(/\d+/g) || []).map(Number);
@@ -65,24 +82,48 @@ export function useGalleryImages({
65
82
  page?: number;
66
83
  }) {
67
84
  const { resolvedTheme } = useTheme();
85
+ const { "*": routePath = "" } = useParams();
68
86
 
69
- let resolvedPath = path;
87
+ // Get the current post's directory from the route
88
+ // Route is like "04-components/README.mdx" -> "04-components"
89
+ // Or "gallery-examples" -> "gallery-examples"
90
+ const currentDir = routePath
91
+ .replace(/\/[^/]+\.mdx$/i, "") // Remove /filename.mdx
92
+ .replace(/\/$/, "") // Remove trailing slash
93
+ || ".";
70
94
 
71
- const { directory } = useDirectory(resolvedPath);
95
+ // Resolve the path relative to current directory
96
+ let resolvedPath = path;
97
+ if (path?.startsWith("./")) {
98
+ // Relative path like "./images" -> "gallery-examples/images"
99
+ const relativePart = path.slice(2);
100
+ resolvedPath = currentDir === "." ? relativePart : `${currentDir}/${relativePart}`;
101
+ } else if (path && !path.startsWith("/") && !path.includes("/")) {
102
+ // Simple name like "images" -> "gallery-examples/images"
103
+ resolvedPath = currentDir === "." ? path : `${currentDir}/${path}`;
104
+ }
105
+
106
+ // If only globs provided (no path), use root directory
107
+ const directoryPath = resolvedPath || ".";
108
+ const { directory } = useDirectory(directoryPath);
72
109
 
73
110
  const paths = useMemo(() => {
74
111
  if (!directory) return [];
75
112
 
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);
113
+ let imagePaths: string[];
81
114
 
82
115
  if (globs && globs.length > 0) {
83
- imagePaths = imagePaths.filter(p => {
84
- return globs.some(glob => minimatch(p.split('/').pop() || '', glob));
116
+ // When globs provided, collect all images recursively and match against filename
117
+ const allImages = collectAllImages(directory);
118
+ imagePaths = allImages
119
+ .map(img => img.path)
120
+ .filter(p => globs.some(glob => minimatch(p, glob, { matchBase: true })));
121
+ } else {
122
+ // No globs - just get images from the specified directory
123
+ const imageChildren = directory.children.filter((child): child is FileEntry => {
124
+ return !!child.name.match(/\.(png|jpg|jpeg|gif|svg|webp)$/i) && child.type === "file";
85
125
  });
126
+ imagePaths = imageChildren.map(child => child.path);
86
127
  }
87
128
 
88
129
  sortPathsNumerically(imagePaths);
@@ -62,63 +62,69 @@ export default function Gallery({
62
62
 
63
63
  if (isLoading) {
64
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">
65
+ <figure className="not-prose py-6 md:py-8">
66
+ <div className="grid grid-cols-3 gap-3 max-w-[var(--gallery-width)] mx-auto">
67
67
  {[...Array(3)].map((_, i) => (
68
68
  <div
69
69
  key={i}
70
- className="aspect-[4/3] bg-muted/30 animate-pulse"
71
- style={{ animationDelay: `${i * 100}ms` }}
72
- />
70
+ className="aspect-square rounded-sm bg-muted/20 relative overflow-hidden"
71
+ >
72
+ <div
73
+ className="absolute inset-0 bg-gradient-to-r from-transparent via-muted/30 to-transparent animate-shimmer"
74
+ style={{ animationDelay: `${i * 150}ms` }}
75
+ />
76
+ </div>
73
77
  ))}
74
78
  </div>
75
- </div>
79
+ </figure>
76
80
  );
77
81
  }
78
82
 
79
83
  if (isEmpty) {
80
84
  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
+ <figure className="not-prose py-12 text-center">
86
+ <div className="inline-flex items-center gap-2.5 text-muted-foreground/40">
87
+ <Image className="h-3.5 w-3.5" strokeWidth={1.5} />
88
+ <span className="font-mono text-xs uppercase tracking-widest">No images</span>
85
89
  </div>
86
- </div>
90
+ </figure>
87
91
  );
88
92
  }
89
93
 
90
94
  return (
91
95
  <>
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))]">
96
+ <figure className="not-prose relative py-6 md:py-8 -mx-[calc((var(--gallery-width)-var(--content-width))/2+var(--page-padding))]">
93
97
  <FigureHeader title={title} subtitle={subtitle} />
94
98
 
95
- <Carousel>
96
- <CarouselContent>
99
+ <Carousel className="w-full">
100
+ <CarouselContent className={`-ml-2 md:-ml-3 ${images.length < 3 ? 'justify-center' : ''}`}>
97
101
  {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
+ <CarouselItem
103
+ key={index}
104
+ className={`pl-2 md:pl-3 md:basis-1/2 lg:basis-1/3 cursor-pointer group ${images.length < 3 ? 'flex-none' : ''}`}
102
105
  onClick={() => lightbox.open(index)}
103
106
  >
104
- <LoadingImage
105
- src={img.src}
106
- alt={img.label}
107
- />
107
+ <div className="aspect-square overflow-hidden rounded-sm ring-1 ring-border/50 transition-all duration-300 group-hover:ring-border group-hover:shadow-lg bg-muted/10">
108
+ <LoadingImage
109
+ src={img.src}
110
+ alt={img.label}
111
+ className="object-contain transition-transform duration-500 ease-out group-hover:scale-[1.02]"
112
+ />
113
+ </div>
108
114
  </CarouselItem>
109
115
  ))}
110
116
  </CarouselContent>
111
117
 
112
118
  {images.length > 3 && (
113
119
  <>
114
- <CarouselPrevious />
115
- <CarouselNext />
120
+ <CarouselPrevious className="left-4 bg-background/80 backdrop-blur-sm border-border/50 hover:bg-background hover:border-border" />
121
+ <CarouselNext className="right-4 bg-background/80 backdrop-blur-sm border-border/50 hover:bg-background hover:border-border" />
116
122
  </>
117
- )}
123
+ )}
118
124
  </Carousel>
119
125
 
120
126
  <FigureCaption caption={caption} label={captionLabel} />
121
- </div>
127
+ </figure>
122
128
 
123
129
  {lightbox.isOpen && lightbox.selectedIndex !== null && (
124
130
  <Lightbox
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
2
2
  import { ModeToggle } from "./mode-toggle";
3
3
  import { SiGithub } from "@icons-pack/react-simple-icons";
4
4
  import { ChevronUp, ChevronDown } from "lucide-react";
5
+ import siteConfig from "virtual:veslx-config";
5
6
 
6
7
  interface HeaderProps {
7
8
  slideControls?: {
@@ -13,6 +14,8 @@ interface HeaderProps {
13
14
  }
14
15
 
15
16
  export function Header({ slideControls }: HeaderProps = {}) {
17
+ const config = siteConfig;
18
+
16
19
  return (
17
20
  <header className="print:hidden fixed top-0 left-0 right-0 z-40">
18
21
  <div className="mx-auto w-full px-[var(--page-padding)] flex items-center gap-8 py-4">
@@ -21,7 +24,7 @@ export function Header({ slideControls }: HeaderProps = {}) {
21
24
  to="/"
22
25
  className="rounded-lg font-mono py-1.5 text-sm font-medium text-muted-foreground hover:underline"
23
26
  >
24
- pl
27
+ {config.name}
25
28
  </Link>
26
29
  </nav>
27
30
 
@@ -52,14 +55,16 @@ export function Header({ slideControls }: HeaderProps = {}) {
52
55
 
53
56
  {/* Navigation */}
54
57
  <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>
58
+ {config.github && (
59
+ <Link
60
+ to={`https://github.com/${config.github}`}
61
+ target="_blank"
62
+ className="text-muted-foreground/70 hover:text-foreground transition-colors duration-300"
63
+ aria-label="GitHub"
64
+ >
65
+ <SiGithub className="h-4 w-4" />
66
+ </Link>
67
+ )}
63
68
  <ModeToggle />
64
69
  </nav>
65
70
  </div>
@@ -1,7 +1,62 @@
1
-
1
+ import { Link, useLocation } from 'react-router-dom'
2
2
  import Gallery from '@/components/gallery'
3
3
  import { ParameterTable } from '@/components/parameter-table'
4
4
  import { ParameterBadge } from '@/components/parameter-badge'
5
+ import { FrontMatter } from './front-matter'
6
+
7
+ /**
8
+ * Smart link component that uses React Router for internal links
9
+ * and regular anchor tags for external links.
10
+ */
11
+ function SmartLink({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
12
+ const location = useLocation()
13
+
14
+ // External links: absolute URLs, mailto, tel, etc.
15
+ const isExternal = href?.startsWith('http') || href?.startsWith('mailto:') || href?.startsWith('tel:')
16
+
17
+ // Hash-only links stay as anchors for in-page navigation
18
+ const isHashOnly = href?.startsWith('#')
19
+
20
+ if (isExternal || isHashOnly || !href) {
21
+ return (
22
+ <a
23
+ href={href}
24
+ className="text-primary hover:underline underline-offset-2"
25
+ {...(isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
26
+ {...props}
27
+ >
28
+ {children}
29
+ </a>
30
+ )
31
+ }
32
+
33
+ // Resolve relative paths (./foo.mdx, ../bar.mdx) against current location
34
+ let resolvedHref = href
35
+ if (href.startsWith('./') || href.startsWith('../')) {
36
+ // Get current directory from pathname
37
+ const currentPath = location.pathname
38
+ const currentDir = currentPath.replace(/\/[^/]+\.mdx$/, '') || currentPath.replace(/\/[^/]*$/, '') || '/'
39
+
40
+ // Simple relative path resolution
41
+ if (href.startsWith('./')) {
42
+ resolvedHref = `${currentDir}/${href.slice(2)}`.replace(/\/+/g, '/')
43
+ } else if (href.startsWith('../')) {
44
+ const parentDir = currentDir.replace(/\/[^/]+$/, '') || '/'
45
+ resolvedHref = `${parentDir}/${href.slice(3)}`.replace(/\/+/g, '/')
46
+ }
47
+ }
48
+
49
+ // Internal link - use React Router Link
50
+ return (
51
+ <Link
52
+ to={resolvedHref}
53
+ className="text-primary hover:underline underline-offset-2"
54
+ {...props}
55
+ >
56
+ {children}
57
+ </Link>
58
+ )
59
+ }
5
60
 
6
61
  function generateId(children: unknown): string {
7
62
  return children
@@ -13,6 +68,9 @@ function generateId(children: unknown): string {
13
68
 
14
69
  // Shared MDX components - lab notebook / coder aesthetic
15
70
  export const mdxComponents = {
71
+
72
+ FrontMatter,
73
+
16
74
  Gallery,
17
75
 
18
76
  ParameterTable,
@@ -110,13 +168,8 @@ export const mdxComponents = {
110
168
  <li className="mt-1.5" {...props} />
111
169
  ),
112
170
 
113
- // Links
114
- a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
115
- <a
116
- className="text-primary hover:underline underline-offset-2"
117
- {...props}
118
- />
119
- ),
171
+ // Links - uses React Router for internal navigation
172
+ a: SmartLink,
120
173
 
121
174
  // Tables
122
175
  table: (props: React.TableHTMLAttributes<HTMLTableElement>) => (
@@ -1,14 +1,64 @@
1
1
  import { Link } from "react-router-dom";
2
2
  import { cn } from "@/lib/utils";
3
- import { DirectoryEntry } from "../../plugin/src/lib";
4
- import { findReadme, findSlides } from "../../plugin/src/client";
3
+ import { DirectoryEntry, FileEntry } from "../../plugin/src/lib";
4
+ import { findReadme, findSlides, findMdxFiles } from "../../plugin/src/client";
5
5
  import { formatDate } from "@/lib/format-date";
6
6
  import { ArrowRight } from "lucide-react";
7
7
 
8
+ type PostEntry = {
9
+ type: 'folder' | 'file';
10
+ name: string;
11
+ path: string;
12
+ readme: FileEntry | null;
13
+ slides: FileEntry | null;
14
+ file: FileEntry | null; // For standalone MDX files
15
+ };
16
+
17
+ // Helper to extract numeric prefix from filename (e.g., "01-intro" → 1)
18
+ function extractOrder(name: string): number | null {
19
+ const match = name.match(/^(\d+)-/);
20
+ return match ? parseInt(match[1], 10) : null;
21
+ }
22
+
23
+ // Helper to strip numeric prefix for display (e.g., "01-getting-started" → "Getting Started")
24
+ function stripNumericPrefix(name: string): string {
25
+ return name
26
+ .replace(/^\d+-/, '')
27
+ .replace(/-/g, ' ')
28
+ .replace(/\b\w/g, c => c.toUpperCase());
29
+ }
30
+
8
31
  export default function PostList({ directory }: { directory: DirectoryEntry }) {
9
32
  const folders = directory.children.filter((c): c is DirectoryEntry => c.type === "directory");
33
+ const standaloneFiles = findMdxFiles(directory);
34
+
35
+ // Convert folders to post entries
36
+ const folderPosts: PostEntry[] = folders.map((folder) => {
37
+ const readme = findReadme(folder);
38
+ const slides = findSlides(folder);
39
+ return {
40
+ type: 'folder' as const,
41
+ name: folder.name,
42
+ path: folder.path,
43
+ readme,
44
+ slides,
45
+ file: null,
46
+ };
47
+ });
10
48
 
11
- if (folders.length === 0) {
49
+ // Convert standalone MDX files to post entries
50
+ const filePosts: PostEntry[] = standaloneFiles.map((file) => ({
51
+ type: 'file' as const,
52
+ name: file.name.replace(/\.mdx?$/, ''),
53
+ path: file.path,
54
+ readme: null,
55
+ slides: null,
56
+ file,
57
+ }));
58
+
59
+ let posts: PostEntry[] = [...folderPosts, ...filePosts];
60
+
61
+ if (posts.length === 0) {
12
62
  return (
13
63
  <div className="py-24 text-center">
14
64
  <p className="text-muted-foreground font-mono text-sm tracking-wide">no entries</p>
@@ -16,133 +66,117 @@ export default function PostList({ directory }: { directory: DirectoryEntry }) {
16
66
  );
17
67
  }
18
68
 
19
- let posts = folders.map((folder) => {
20
- const readme = findReadme(folder);
21
- const slides = findSlides(folder);
22
- return {
23
- ...folder,
24
- readme,
25
- slides,
26
- }
27
- })
28
-
69
+ // Filter out hidden and draft posts
29
70
  posts = posts.filter((post) => {
30
- return post.readme?.frontmatter?.visibility !== "hidden";
71
+ const frontmatter = post.readme?.frontmatter || post.file?.frontmatter;
72
+ return frontmatter?.visibility !== "hidden" && frontmatter?.draft !== true;
31
73
  });
32
74
 
75
+ // Helper to get frontmatter from post
76
+ const getFrontmatter = (post: PostEntry) => {
77
+ return post.readme?.frontmatter || post.file?.frontmatter || post.slides?.frontmatter;
78
+ };
79
+
80
+ // Helper to get date from post
81
+ const getPostDate = (post: PostEntry): Date | null => {
82
+ const frontmatter = getFrontmatter(post);
83
+ return frontmatter?.date ? new Date(frontmatter.date as string) : null;
84
+ };
85
+
86
+ // Smart sorting: numeric prefix → date → alphabetical
33
87
  posts = posts.sort((a, b) => {
34
- let aDate = a.readme?.frontmatter?.date ? new Date(a.readme.frontmatter.date as string) : null;
35
- let bDate = b.readme?.frontmatter?.date ? new Date(b.readme.frontmatter.date as string) : null;
88
+ const aOrder = extractOrder(a.name);
89
+ const bOrder = extractOrder(b.name);
90
+ const aDate = getPostDate(a);
91
+ const bDate = getPostDate(b);
36
92
 
37
- if (!aDate && a.slides) {
38
- aDate = a.slides.frontmatter?.date ? new Date(a.slides.frontmatter.date as string) : null;
39
- }
40
- if (!bDate && b.slides) {
41
- bDate = b.slides.frontmatter?.date ? new Date(b.slides.frontmatter.date as string) : null;
93
+ // Both have numeric prefix → sort by number
94
+ if (aOrder !== null && bOrder !== null) {
95
+ return aOrder - bOrder;
42
96
  }
97
+ // One has prefix, one doesn't → prefixed comes first
98
+ if (aOrder !== null) return -1;
99
+ if (bOrder !== null) return 1;
43
100
 
101
+ // Both have dates → sort by date (newest first)
44
102
  if (aDate && bDate) {
45
103
  return bDate.getTime() - aDate.getTime();
46
- } else if (aDate) {
47
- return -1;
48
- } else if (bDate) {
49
- return 1;
50
- } else {
51
- return a.name.localeCompare(b.name);
52
104
  }
53
- });
105
+ // One has date → dated comes first
106
+ if (aDate) return -1;
107
+ if (bDate) return 1;
54
108
 
55
- const postsGroupedByMonthAndYear: { [key: string]: typeof posts } = {};
56
- posts.forEach((post) => {
57
- let date = post.readme?.frontmatter?.date ? new Date(post.readme.frontmatter.date as string) : null;
58
- if (!date && post.slides) {
59
- date = post.slides.frontmatter?.date ? new Date(post.slides.frontmatter.date as string) : null;
60
- }
61
- const monthYear = date ? `${date.getFullYear()}-${date.getMonth() + 1}` : "unknown";
62
- if (!postsGroupedByMonthAndYear[monthYear]) {
63
- postsGroupedByMonthAndYear[monthYear] = [];
64
- }
65
- postsGroupedByMonthAndYear[monthYear].push(post);
109
+ // Neither alphabetical by title
110
+ const aTitle = (getFrontmatter(a)?.title as string) || a.name;
111
+ const bTitle = (getFrontmatter(b)?.title as string) || b.name;
112
+ return aTitle.localeCompare(bTitle);
66
113
  });
67
114
 
68
115
  return (
69
- <div className="space-y-8">
70
- {Object.entries(postsGroupedByMonthAndYear)
71
- .sort(([a], [b]) => {
72
- if (a === "unknown") return 1;
73
- if (b === "unknown") return -1;
74
- return b.localeCompare(a);
75
- })
76
- .map(([monthYear, monthPosts]) => {
77
- const [year, month] = monthYear.split("-");
78
- const displayDate = monthYear === "unknown"
79
- ? "Unknown Date"
80
- : new Date(parseInt(year), parseInt(month) - 1).toLocaleDateString("en-US", {
81
- year: "numeric",
82
- month: "long"
83
- });
84
-
85
- return (
86
- <div key={monthYear}>
87
- <h2 className="text-xs font-mono uppercase tracking-wider text-muted-foreground mb-3">
88
- {displayDate}
89
- </h2>
90
- <div className="space-y-1">
91
- {monthPosts.map((post) => {
92
- let frontmatter = post.readme?.frontmatter;
93
-
94
- if (!post.readme && post.slides) {
95
- frontmatter = post.slides.frontmatter;
96
- }
97
-
98
- const title = (frontmatter?.title as string) || post.name;
99
- const description = frontmatter?.description as string | undefined;
100
- const date = frontmatter?.date ? new Date(frontmatter.date as string) : null;
101
-
102
- return (
103
- <Link
104
- key={post.path}
105
- to={(post.slides && !post.readme) ? `/${post.slides.path}` : `/${post.readme.path}`}
106
- className={cn(
107
- "group block py-3 px-3 -mx-3 rounded-md",
108
- "transition-colors duration-150",
109
- // "hover:bg-accent"
110
- )}
111
- >
112
- <article className="flex items-start gap-4">
113
- {/* Date - left side, fixed width */}
114
- <time
115
- dateTime={date?.toISOString()}
116
- className="font-mono text-xs text-muted-foreground tabular-nums w-20 flex-shrink-0 pt-0.5"
117
- >
118
- {date ? formatDate(date) : <span className="text-muted-foreground/30">—</span>}
119
- </time>
120
-
121
- {/* Main content */}
122
- <div className="flex-1 min-w-0">
123
- <h3 className={cn(
124
- "text-sm font-medium text-foreground",
125
- "group-hover:underline",
126
- "flex items-center gap-2"
127
- )}>
128
- <span>{title}</span>
129
- <ArrowRight className="h-3 w-3 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200 text-primary" />
130
- </h3>
131
-
132
- {description && (
133
- <p className="text-sm text-muted-foreground line-clamp-1 mt-0.5">
134
- {description}
135
- </p>
136
- )}
137
- </div>
138
- </article>
139
- </Link>
140
- );
141
- })}
116
+ <div className="space-y-1">
117
+ {posts.map((post) => {
118
+ const frontmatter = getFrontmatter(post);
119
+
120
+ // Title: explicit frontmatter > stripped numeric prefix > raw name
121
+ const title = (frontmatter?.title as string) || stripNumericPrefix(post.name);
122
+ const description = frontmatter?.description as string | undefined;
123
+ const date = frontmatter?.date ? new Date(frontmatter.date as string) : null;
124
+
125
+ // Determine the link path
126
+ let linkPath: string;
127
+ if (post.file) {
128
+ // Standalone MDX file
129
+ linkPath = `/${post.file.path}`;
130
+ } else if (post.slides && !post.readme) {
131
+ // Folder with only slides
132
+ linkPath = `/${post.slides.path}`;
133
+ } else if (post.readme) {
134
+ // Folder with readme
135
+ linkPath = `/${post.readme.path}`;
136
+ } else {
137
+ // Fallback to folder path
138
+ linkPath = `/${post.path}`;
139
+ }
140
+
141
+ return (
142
+ <Link
143
+ key={post.path}
144
+ to={linkPath}
145
+ className={cn(
146
+ "group block py-3 px-3 -mx-3 rounded-md",
147
+ "transition-colors duration-150",
148
+ )}
149
+ >
150
+ <article className="flex items-start gap-4">
151
+ {/* Date - left side, fixed width */}
152
+ <time
153
+ dateTime={date?.toISOString()}
154
+ className="font-mono text-xs text-muted-foreground tabular-nums w-20 flex-shrink-0 pt-0.5"
155
+ >
156
+ {date ? formatDate(date) : <span className="text-muted-foreground/30">—</span>}
157
+ </time>
158
+
159
+ {/* Main content */}
160
+ <div className="flex-1 min-w-0">
161
+ <h3 className={cn(
162
+ "text-sm font-medium text-foreground",
163
+ "group-hover:underline",
164
+ "flex items-center gap-2"
165
+ )}>
166
+ <span>{title}</span>
167
+ <ArrowRight className="h-3 w-3 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200 text-primary" />
168
+ </h3>
169
+
170
+ {description && (
171
+ <p className="text-sm text-muted-foreground line-clamp-1 mt-0.5">
172
+ {description}
173
+ </p>
174
+ )}
142
175
  </div>
143
- </div>
144
- );
145
- })}
176
+ </article>
177
+ </Link>
178
+ );
179
+ })}
146
180
  </div>
147
181
  );
148
182
  }
@@ -8,7 +8,7 @@ export function RunningBar() {
8
8
  <>
9
9
  {isRunning && (
10
10
  // this should stay red not another color
11
- <div className="sticky top-0 z-50 px-[var(--page-padding)] py-2 bg-red-500 text-primary-foreground font-mono text-xs text-center tracking-wide">
11
+ <div className="running-bar sticky top-0 z-50 px-[var(--page-padding)] py-2 bg-red-500 text-primary-foreground font-mono text-xs text-center tracking-wide">
12
12
  <span className="inline-flex items-center gap-3">
13
13
  <span className="h-1.5 w-1.5 rounded-full bg-current animate-pulse" />
14
14
  <span className="uppercase tracking-widest">simulation running</span>