veslx 0.1.35 → 0.1.38

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 (37) hide show
  1. package/bin/lib/build.ts +0 -12
  2. package/bin/lib/export.ts +0 -12
  3. package/bin/lib/serve.ts +9 -14
  4. package/dist/client/components/footer.js +21 -0
  5. package/dist/client/components/footer.js.map +1 -0
  6. package/dist/client/components/gallery/index.js +21 -2
  7. package/dist/client/components/gallery/index.js.map +1 -1
  8. package/dist/client/components/mdx-components.js +2 -0
  9. package/dist/client/components/mdx-components.js.map +1 -1
  10. package/dist/client/components/parameter-table.js +163 -23
  11. package/dist/client/components/parameter-table.js.map +1 -1
  12. package/dist/client/components/post-list.js +7 -1
  13. package/dist/client/components/post-list.js.map +1 -1
  14. package/dist/client/hooks/use-mdx-content.js +29 -3
  15. package/dist/client/hooks/use-mdx-content.js.map +1 -1
  16. package/dist/client/lib/parameter-utils.js +1 -1
  17. package/dist/client/lib/parameter-utils.js.map +1 -1
  18. package/dist/client/package.json.js +9 -0
  19. package/dist/client/package.json.js.map +1 -0
  20. package/dist/client/pages/home.js +3 -1
  21. package/dist/client/pages/home.js.map +1 -1
  22. package/dist/client/pages/index-post.js +3 -1
  23. package/dist/client/pages/index-post.js.map +1 -1
  24. package/dist/client/pages/post.js +3 -1
  25. package/dist/client/pages/post.js.map +1 -1
  26. package/package.json +1 -1
  27. package/plugin/src/plugin.ts +70 -39
  28. package/src/components/footer.tsx +18 -0
  29. package/src/components/gallery/index.tsx +32 -2
  30. package/src/components/mdx-components.tsx +3 -0
  31. package/src/components/parameter-table.tsx +233 -14
  32. package/src/components/post-list.tsx +14 -1
  33. package/src/hooks/use-mdx-content.ts +54 -14
  34. package/src/lib/parameter-utils.ts +1 -1
  35. package/src/pages/home.tsx +2 -0
  36. package/src/pages/index-post.tsx +5 -2
  37. package/src/pages/post.tsx +3 -1
@@ -310,9 +310,6 @@ export default function contentPlugin(contentDir: string, config?: VeslxConfig,
310
310
  next()
311
311
  }
312
312
 
313
- // Virtual module ID for the modified CSS
314
- const VIRTUAL_CSS_MODULE = '\0veslx:index.css'
315
-
316
313
  return {
317
314
  name: 'content',
318
315
  enforce: 'pre',
@@ -336,17 +333,8 @@ export default function contentPlugin(contentDir: string, config?: VeslxConfig,
336
333
  }
337
334
  },
338
335
 
339
- // Intercept CSS and virtual module imports
340
- resolveId(id, importer) {
341
- // Intercept index.css imported from main.tsx and redirect to our virtual module
342
- // This allows us to inject @source directive for Tailwind to scan user content
343
- if (id === './index.css' && importer?.endsWith('/src/main.tsx')) {
344
- return VIRTUAL_CSS_MODULE
345
- }
346
- // Also catch the resolved path
347
- if (id.endsWith('/src/index.css') && !id.startsWith('\0')) {
348
- return VIRTUAL_CSS_MODULE
349
- }
336
+ // Intercept virtual module imports
337
+ resolveId(id) {
350
338
  // Virtual modules for content
351
339
  if (id === VIRTUAL_MODULE_ID) {
352
340
  return RESOLVED_VIRTUAL_MODULE_ID
@@ -356,28 +344,25 @@ export default function contentPlugin(contentDir: string, config?: VeslxConfig,
356
344
  }
357
345
  },
358
346
 
359
- load(id) {
360
- // Serve the modified CSS content with @source directive
361
- // This enables Tailwind v4 to scan the user's content directory for classes
362
- if (id === VIRTUAL_CSS_MODULE) {
363
- // Read the original CSS
364
- const veslxRoot = path.dirname(path.dirname(__dirname))
365
- const cssPath = path.join(veslxRoot, 'src/index.css')
366
- const cssContent = fs.readFileSync(cssPath, 'utf-8')
367
-
347
+ // Transform CSS to inject @source directive for Tailwind
348
+ // This enables Tailwind v4 to scan the user's content directory for classes
349
+ transform(code, id) {
350
+ if (id.endsWith('/src/index.css') && code.includes("@import 'tailwindcss'")) {
368
351
  // Use absolute path for @source directive
369
352
  const absoluteContentDir = dir.replace(/\\/g, '/')
353
+ const sourceDirective = `@source "${absoluteContentDir}";`
370
354
 
371
355
  // Inject @source directive after the tailwindcss import
372
- const sourceDirective = `@source "${absoluteContentDir}";`
373
- const modified = cssContent.replace(
356
+ const modified = code.replace(
374
357
  /(@import\s+["']tailwindcss["'];?)/,
375
358
  `$1\n${sourceDirective}`
376
359
  )
377
360
 
378
- return modified
361
+ return { code: modified, map: null }
379
362
  }
363
+ },
380
364
 
365
+ load(id) {
381
366
  // Virtual module for content
382
367
  if (id === RESOLVED_VIRTUAL_MODULE_ID) {
383
368
  // Extract frontmatter from MDX files at build time (avoids MDX hook issues)
@@ -385,15 +370,32 @@ export default function contentPlugin(contentDir: string, config?: VeslxConfig,
385
370
 
386
371
  // Generate virtual module with import.meta.glob for MDX files
387
372
  return `
388
- export const posts = import.meta.glob(['@content/**/*.mdx', '@content/**/*.md'], {
373
+ export const posts = import.meta.glob(['@content/*.mdx', '@content/*.md', '@content/**/*.mdx', '@content/**/*.md'], {
389
374
  import: 'default',
390
375
  query: { skipSlides: true }
391
376
  });
392
- export const allMdx = import.meta.glob(['@content/**/*.mdx', '@content/**/*.md']);
393
- export const slides = import.meta.glob(['@content/**/SLIDES.mdx', '@content/**/SLIDES.md', '@content/**/*.slides.mdx', '@content/**/*.slides.md']);
377
+ export const allMdx = import.meta.glob(['@content/*.mdx', '@content/*.md', '@content/**/*.mdx', '@content/**/*.md']);
378
+ export const slides = import.meta.glob(['@content/SLIDES.mdx', '@content/SLIDES.md', '@content/*.slides.mdx', '@content/*.slides.md', '@content/**/SLIDES.mdx', '@content/**/SLIDES.md', '@content/**/*.slides.mdx', '@content/**/*.slides.md']);
394
379
 
395
- // All files for directory tree building (web-compatible files only)
380
+ // All files for directory tree building (using ?url to avoid parsing non-JS files)
381
+ // Exclude veslx.yaml config files from bundling
396
382
  export const files = import.meta.glob([
383
+ '@content/*.mdx',
384
+ '@content/*.md',
385
+ '@content/*.tsx',
386
+ '@content/*.ts',
387
+ '@content/*.jsx',
388
+ '@content/*.js',
389
+ '@content/*.png',
390
+ '@content/*.jpg',
391
+ '@content/*.jpeg',
392
+ '@content/*.gif',
393
+ '@content/*.svg',
394
+ '@content/*.webp',
395
+ '@content/*.css',
396
+ '@content/*.yaml',
397
+ '@content/*.yml',
398
+ '@content/*.json',
397
399
  '@content/**/*.mdx',
398
400
  '@content/**/*.md',
399
401
  '@content/**/*.tsx',
@@ -407,13 +409,18 @@ export const files = import.meta.glob([
407
409
  '@content/**/*.svg',
408
410
  '@content/**/*.webp',
409
411
  '@content/**/*.css',
410
- ], { eager: false });
412
+ '@content/**/*.yaml',
413
+ '@content/**/*.yml',
414
+ '@content/**/*.json',
415
+ '!@content/veslx.yaml',
416
+ '!@content/**/veslx.yaml',
417
+ ], { eager: false, query: '?url', import: 'default' });
411
418
 
412
419
  // Frontmatter extracted at build time (no MDX execution required)
413
420
  export const frontmatters = ${JSON.stringify(frontmatterData)};
414
421
 
415
422
  // Legacy aliases for backwards compatibility
416
- export const modules = import.meta.glob(['@content/**/*.mdx', '@content/**/*.md']);
423
+ export const modules = import.meta.glob(['@content/*.mdx', '@content/*.md', '@content/**/*.mdx', '@content/**/*.md']);
417
424
  `
418
425
  }
419
426
  if (id === RESOLVED_VIRTUAL_CONFIG_ID) {
@@ -438,15 +445,21 @@ export const modules = import.meta.glob(['@content/**/*.mdx', '@content/**/*.md'
438
445
  // File extensions that should trigger a full reload
439
446
  const watchedExtensions = ['.mdx', '.md', '.yaml', '.yml', '.json', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.tsx', '.ts', '.jsx', '.js', '.css']
440
447
 
441
- const handleContentChange = (filePath: string, event: 'add' | 'unlink' | 'change') => {
442
- // Check if the file is in the content directory
443
- if (!filePath.startsWith(dir)) return
448
+ // Debounce reload to avoid rapid-fire refreshes when multiple files change
449
+ let reloadTimeout: ReturnType<typeof setTimeout> | null = null
450
+ const pendingChanges: Array<{ filePath: string; event: 'add' | 'unlink' | 'change' }> = []
451
+ const DEBOUNCE_MS = 1000
444
452
 
445
- // Check if it's a watched file type
446
- const ext = path.extname(filePath).toLowerCase()
447
- if (!watchedExtensions.includes(ext)) return
453
+ const flushReload = () => {
454
+ if (pendingChanges.length === 0) return
448
455
 
449
- console.log(`[veslx] Content ${event}: ${path.relative(dir, filePath)}`)
456
+ // Log all pending changes
457
+ for (const { filePath, event } of pendingChanges) {
458
+ console.log(`[veslx] Content ${event}: ${path.relative(dir, filePath)}`)
459
+ }
460
+
461
+ // Clear pending changes
462
+ pendingChanges.length = 0
450
463
 
451
464
  // Invalidate the virtual content module so frontmatters are re-extracted
452
465
  const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID)
@@ -458,6 +471,24 @@ export const modules = import.meta.glob(['@content/**/*.mdx', '@content/**/*.md'
458
471
  server.ws.send({ type: 'full-reload' })
459
472
  }
460
473
 
474
+ const handleContentChange = (filePath: string, event: 'add' | 'unlink' | 'change') => {
475
+ // Check if the file is in the content directory
476
+ if (!filePath.startsWith(dir)) return
477
+
478
+ // Check if it's a watched file type
479
+ const ext = path.extname(filePath).toLowerCase()
480
+ if (!watchedExtensions.includes(ext)) return
481
+
482
+ // Queue this change
483
+ pendingChanges.push({ filePath, event })
484
+
485
+ // Debounce: clear existing timeout and set a new one
486
+ if (reloadTimeout) {
487
+ clearTimeout(reloadTimeout)
488
+ }
489
+ reloadTimeout = setTimeout(flushReload, DEBOUNCE_MS)
490
+ }
491
+
461
492
  server.watcher.on('add', (filePath) => handleContentChange(filePath, 'add'))
462
493
  server.watcher.on('unlink', (filePath) => handleContentChange(filePath, 'unlink'))
463
494
  server.watcher.on('change', (filePath) => handleContentChange(filePath, 'change'))
@@ -0,0 +1,18 @@
1
+ import packageJson from "../../package.json";
2
+
3
+ export function Footer() {
4
+ return (
5
+ <footer className="py-4 mx-auto w-full max-w-[var(--content-width)] px-[var(--page-padding)]">
6
+ <div className="flex justify-end">
7
+ <a
8
+ href="https://github.com/eoinmurray/veslx"
9
+ target="_blank"
10
+ rel="noopener noreferrer"
11
+ className="font-mono text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors"
12
+ >
13
+ veslx v{packageJson.version}
14
+ </a>
15
+ </div>
16
+ </footer>
17
+ );
18
+ }
@@ -1,4 +1,4 @@
1
- import { useMemo } from "react";
1
+ import { useMemo, useRef, useEffect } from "react";
2
2
  import { Image } from "lucide-react";
3
3
  import { Lightbox, LightboxImage } from "@/components/gallery/components/lightbox";
4
4
  import { useGalleryImages } from "./hooks/use-gallery-images";
@@ -7,6 +7,34 @@ import { LoadingImage } from "./components/loading-image";
7
7
  import { FigureHeader } from "./components/figure-header";
8
8
  import { FigureCaption } from "./components/figure-caption";
9
9
 
10
+ /**
11
+ * Hook to prevent horizontal scroll from triggering browser back/forward gestures.
12
+ * Captures wheel events and prevents default when at scroll boundaries.
13
+ */
14
+ function usePreventSwipeNavigation(ref: React.RefObject<HTMLElement | null>) {
15
+ useEffect(() => {
16
+ const el = ref.current;
17
+ if (!el) return;
18
+
19
+ const handleWheel = (e: WheelEvent) => {
20
+ // Only handle horizontal scrolling
21
+ if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
22
+
23
+ const { scrollLeft, scrollWidth, clientWidth } = el;
24
+ const atLeftEdge = scrollLeft <= 0;
25
+ const atRightEdge = scrollLeft + clientWidth >= scrollWidth - 1;
26
+
27
+ // Prevent default if trying to scroll past boundaries
28
+ if ((atLeftEdge && e.deltaX < 0) || (atRightEdge && e.deltaX > 0)) {
29
+ e.preventDefault();
30
+ }
31
+ };
32
+
33
+ el.addEventListener('wheel', handleWheel, { passive: false });
34
+ return () => el.removeEventListener('wheel', handleWheel);
35
+ }, [ref]);
36
+ }
37
+
10
38
  function getImageLabel(path: string): string {
11
39
  const filename = path.split('/').pop() || path;
12
40
  return filename
@@ -51,6 +79,8 @@ export default function Gallery({
51
79
  });
52
80
 
53
81
  const lightbox = useLightbox(paths.length);
82
+ const scrollRef = useRef<HTMLDivElement>(null);
83
+ usePreventSwipeNavigation(scrollRef);
54
84
 
55
85
  const images: LightboxImage[] = useMemo(() =>
56
86
  paths.map(p => ({ src: getImageUrl(p), label: getImageLabel(p) })),
@@ -142,7 +172,7 @@ export default function Gallery({
142
172
  {images.map((img, index) => imageElement(index, img, 'flex-1'))}
143
173
  </div>
144
174
  ) : (
145
- <div className="flex gap-3 overflow-x-auto overscroll-x-contain pb-4">
175
+ <div ref={scrollRef} className="flex gap-3 overflow-x-auto overscroll-x-contain pb-4">
146
176
  {images.map((img, index) => (
147
177
  <div
148
178
  key={index}
@@ -8,6 +8,7 @@ import { FigureSlide } from './slides/figure-slide'
8
8
  import { TextSlide } from './slides/text-slide'
9
9
  import { SlideOutline } from './slides/slide-outline'
10
10
  import { PostList } from '@/components/post-list'
11
+ import { PostListItem } from '@/components/post-list-item'
11
12
  /**
12
13
  * Smart link component that uses React Router for internal links
13
14
  * and regular anchor tags for external links.
@@ -91,6 +92,8 @@ export const mdxComponents = {
91
92
 
92
93
  PostList,
93
94
 
95
+ PostListItem,
96
+
94
97
  // Headings - clean sans-serif
95
98
  h1: (props: React.HTMLAttributes<HTMLHeadingElement>) => {
96
99
  const id = generateId(props.children)
@@ -1,6 +1,8 @@
1
- import { useFileContent } from "../../plugin/src/client";
2
- import { useMemo, useState } from "react";
1
+ import { useFileContent, useDirectory } from "../../plugin/src/client";
2
+ import { useMemo, useState, useRef, useEffect } from "react";
3
+ import { useParams } from "react-router-dom";
3
4
  import { cn } from "@/lib/utils";
5
+ import { minimatch } from "minimatch";
4
6
  import {
5
7
  type ParameterValue,
6
8
  extractPath,
@@ -8,6 +10,67 @@ import {
8
10
  formatValue,
9
11
  parseConfigFile,
10
12
  } from "@/lib/parameter-utils";
13
+ import { FileEntry, DirectoryEntry } from "../../plugin/src/lib";
14
+
15
+ /**
16
+ * Hook to prevent horizontal scroll from triggering browser back/forward gestures.
17
+ */
18
+ function usePreventSwipeNavigation(ref: React.RefObject<HTMLElement | null>) {
19
+ useEffect(() => {
20
+ const el = ref.current;
21
+ if (!el) return;
22
+
23
+ const handleWheel = (e: WheelEvent) => {
24
+ if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
25
+
26
+ const { scrollLeft, scrollWidth, clientWidth } = el;
27
+ const atLeftEdge = scrollLeft <= 0;
28
+ const atRightEdge = scrollLeft + clientWidth >= scrollWidth - 1;
29
+
30
+ if ((atLeftEdge && e.deltaX < 0) || (atRightEdge && e.deltaX > 0)) {
31
+ e.preventDefault();
32
+ }
33
+ };
34
+
35
+ el.addEventListener('wheel', handleWheel, { passive: false });
36
+ return () => el.removeEventListener('wheel', handleWheel);
37
+ }, [ref]);
38
+ }
39
+
40
+ // Check if a path contains glob patterns
41
+ function isGlobPattern(path: string): boolean {
42
+ return path.includes('*') || path.includes('?') || path.includes('[');
43
+ }
44
+
45
+ // Recursively collect all config files from a directory tree
46
+ function collectAllConfigFiles(entry: DirectoryEntry | FileEntry): FileEntry[] {
47
+ if (entry.type === "file") {
48
+ if (entry.name.match(/\.(yaml|yml|json)$/i)) {
49
+ return [entry];
50
+ }
51
+ return [];
52
+ }
53
+ const files: FileEntry[] = [];
54
+ for (const child of entry.children || []) {
55
+ files.push(...collectAllConfigFiles(child));
56
+ }
57
+ return files;
58
+ }
59
+
60
+ // Sort paths numerically
61
+ function sortPathsNumerically(paths: string[]): void {
62
+ paths.sort((a, b) => {
63
+ const nums = (s: string) => (s.match(/\d+/g) || []).map(Number);
64
+ const na = nums(a);
65
+ const nb = nums(b);
66
+ const len = Math.max(na.length, nb.length);
67
+ for (let i = 0; i < len; i++) {
68
+ const diff = (na[i] ?? 0) - (nb[i] ?? 0);
69
+ if (diff !== 0) return diff;
70
+ }
71
+ return a.localeCompare(b);
72
+ });
73
+ }
11
74
 
12
75
  /**
13
76
  * Build a filtered data object from an array of jq-like paths.
@@ -59,12 +122,13 @@ function ParameterGrid({ entries }: { entries: [string, ParameterValue][] }) {
59
122
  </span>
60
123
  <span
61
124
  className={cn(
62
- "text-[11px] font-mono tabular-nums font-medium shrink-0",
125
+ "text-[11px] font-mono tabular-nums font-medium truncate max-w-[120px]",
63
126
  type === "number" && "text-foreground",
64
127
  type === "string" && "text-amber-600 dark:text-amber-500",
65
128
  type === "boolean" && "text-cyan-600 dark:text-cyan-500",
66
129
  type === "null" && "text-muted-foreground/50"
67
130
  )}
131
+ title={type === "string" ? `"${formatValue(value)}"` : formatValue(value)}
68
132
  >
69
133
  {type === "string" ? `"${formatValue(value)}"` : formatValue(value)}
70
134
  </span>
@@ -184,7 +248,7 @@ function ParameterSection({
184
248
  );
185
249
  }
186
250
 
187
- interface ParameterTableProps {
251
+ interface SingleParameterTableProps {
188
252
  /** Path to the YAML or JSON file */
189
253
  path: string;
190
254
  /**
@@ -195,6 +259,19 @@ interface ParameterTableProps {
195
259
  * - [".default_inputs", ".base.dt"] → show default_inputs section and dt from base
196
260
  */
197
261
  keys?: string[];
262
+ /** Optional label to show above the table */
263
+ label?: string;
264
+ /** Whether to include vertical margin (default true) */
265
+ withMargin?: boolean;
266
+ }
267
+
268
+ interface ParameterTableProps {
269
+ /** Path to the YAML or JSON file, supports glob patterns like "*.yaml" */
270
+ path: string;
271
+ /**
272
+ * Optional array of jq-like paths to filter which parameters to show.
273
+ */
274
+ keys?: string[];
198
275
  }
199
276
 
200
277
  /**
@@ -282,20 +359,34 @@ function splitIntoColumns<T extends [string, ParameterValue]>(
282
359
  return columns;
283
360
  }
284
361
 
285
- export function ParameterTable({ path, keys }: ParameterTableProps) {
362
+ function SingleParameterTable({ path, keys, label, withMargin = true }: SingleParameterTableProps) {
286
363
  const { content, loading, error } = useFileContent(path);
287
364
 
288
- const parsed = useMemo(() => {
289
- if (!content) return null;
365
+ const { parsed, parseError } = useMemo(() => {
366
+ if (!content) return { parsed: null, parseError: 'no content' };
290
367
 
291
368
  const data = parseConfigFile(content, path);
292
- if (!data) return null;
369
+ if (!data) {
370
+ // Check why parsing failed
371
+ if (!path.match(/\.(yaml|yml|json)$/i)) {
372
+ return { parsed: null, parseError: `unsupported file type` };
373
+ }
374
+ // Check if content looks like HTML (404 page)
375
+ if (content.trim().startsWith('<!') || content.trim().startsWith('<html')) {
376
+ return { parsed: null, parseError: `file not found` };
377
+ }
378
+ return { parsed: null, parseError: `invalid ${path.split('.').pop()} syntax` };
379
+ }
293
380
 
294
381
  if (keys && keys.length > 0) {
295
- return filterData(data, keys);
382
+ const filtered = filterData(data, keys);
383
+ if (Object.keys(filtered).length === 0) {
384
+ return { parsed: null, parseError: `keys not found: ${keys.join(', ')}` };
385
+ }
386
+ return { parsed: filtered, parseError: null };
296
387
  }
297
388
 
298
- return data;
389
+ return { parsed: data, parseError: null };
299
390
  }, [content, path, keys]);
300
391
 
301
392
  if (loading) {
@@ -319,8 +410,11 @@ export function ParameterTable({ path, keys }: ParameterTableProps) {
319
410
 
320
411
  if (!parsed) {
321
412
  return (
322
- <div className="my-6 p-3 rounded border border-border/50 bg-card/30">
323
- <p className="text-[11px] font-mono text-muted-foreground">unable to parse config</p>
413
+ <div className={cn("p-3 rounded border border-border/50 bg-card/30", withMargin && "my-6")}>
414
+ <p className="text-[11px] font-mono text-muted-foreground">
415
+ {label && <span className="text-foreground/60">{label}: </span>}
416
+ {parseError || 'unable to parse'}
417
+ </p>
324
418
  </div>
325
419
  );
326
420
  }
@@ -390,8 +484,13 @@ export function ParameterTable({ path, keys }: ParameterTableProps) {
390
484
  };
391
485
 
392
486
  return (
393
- <div className="my-6 not-prose">
394
- <div className="rounded border border-border/60 bg-card/20 p-3 overflow-hidden">
487
+ <div className={cn("not-prose", withMargin && "my-6")}>
488
+ {label && (
489
+ <div className="text-[11px] font-mono text-muted-foreground mb-1.5 truncate" title={label}>
490
+ {label}
491
+ </div>
492
+ )}
493
+ <div className="rounded border border-border/60 bg-card/20 p-3 overflow-hidden max-w-full">
395
494
  {topLeaves.length > 0 && (
396
495
  <div className={cn(topNested.length > 0 && "mb-4 pb-3 border-b border-border/30")}>
397
496
  <ParameterGrid entries={topLeaves} />
@@ -418,3 +517,123 @@ export function ParameterTable({ path, keys }: ParameterTableProps) {
418
517
  </div>
419
518
  );
420
519
  }
520
+
521
+ /**
522
+ * ParameterTable component that displays YAML/JSON config files.
523
+ * Supports glob patterns in the path prop to show multiple files.
524
+ */
525
+ export function ParameterTable({ path, keys }: ParameterTableProps) {
526
+ const { "*": routePath = "" } = useParams();
527
+
528
+ // Get current directory from route
529
+ const currentDir = routePath
530
+ .replace(/\/?[^/]+\.mdx$/i, "")
531
+ .replace(/\/$/, "")
532
+ || ".";
533
+
534
+ // Resolve relative paths
535
+ let resolvedPath = path;
536
+ if (path?.startsWith("./")) {
537
+ const relativePart = path.slice(2);
538
+ resolvedPath = currentDir === "." ? relativePart : `${currentDir}/${relativePart}`;
539
+ } else if (path && !path.startsWith("/") && !path.includes("/") && !isGlobPattern(path)) {
540
+ resolvedPath = currentDir === "." ? path : `${currentDir}/${path}`;
541
+ }
542
+
543
+ // Check if this is a glob pattern
544
+ const hasGlob = isGlobPattern(resolvedPath);
545
+
546
+ // For glob patterns, get the base directory (directory containing the glob pattern)
547
+ const baseDir = useMemo(() => {
548
+ if (!hasGlob) return null;
549
+ // Get everything before the first glob character
550
+ const beforeGlob = resolvedPath.split(/[*?\[]/, 1)[0];
551
+ // Extract directory portion (everything up to the last slash)
552
+ const lastSlash = beforeGlob.lastIndexOf('/');
553
+ if (lastSlash === -1) return ".";
554
+ return beforeGlob.slice(0, lastSlash) || ".";
555
+ }, [hasGlob, resolvedPath]);
556
+
557
+ const { directory } = useDirectory(baseDir || ".");
558
+
559
+ // Find matching files for glob patterns
560
+ const matchingPaths = useMemo(() => {
561
+ if (!hasGlob || !directory) return [];
562
+
563
+ const allFiles = collectAllConfigFiles(directory);
564
+ const paths = allFiles
565
+ .map(f => f.path)
566
+ .filter(p => minimatch(p, resolvedPath, { matchBase: true }));
567
+
568
+ sortPathsNumerically(paths);
569
+
570
+ // Debug logging
571
+ console.log('[ParameterTable]', {
572
+ original: path,
573
+ resolved: resolvedPath,
574
+ baseDir,
575
+ allConfigFiles: allFiles.map(f => f.path),
576
+ matched: paths
577
+ });
578
+
579
+ return paths;
580
+ }, [hasGlob, directory, resolvedPath, path, baseDir]);
581
+
582
+ // If not a glob pattern, just render the single table
583
+ if (!hasGlob) {
584
+ return <SingleParameterTable path={resolvedPath} keys={keys} />;
585
+ }
586
+
587
+ // Loading state for glob patterns
588
+ if (!directory) {
589
+ return (
590
+ <div className="my-6 p-4 rounded border border-border/50 bg-card/30">
591
+ <div className="flex items-center gap-2 text-muted-foreground/60">
592
+ <div className="w-3 h-3 border border-current border-t-transparent rounded-full animate-spin" />
593
+ <span className="text-[11px] font-mono">loading parameters...</span>
594
+ </div>
595
+ </div>
596
+ );
597
+ }
598
+
599
+ // No matches
600
+ if (matchingPaths.length === 0) {
601
+ return (
602
+ <div className="my-6 p-3 rounded border border-border/50 bg-card/30">
603
+ <p className="text-[11px] font-mono text-muted-foreground">
604
+ no files matching: {resolvedPath}
605
+ <br />
606
+ <span className="text-muted-foreground/50">(base dir: {baseDir}, original: {path})</span>
607
+ </p>
608
+ </div>
609
+ );
610
+ }
611
+
612
+ const scrollRef = useRef<HTMLDivElement>(null);
613
+ usePreventSwipeNavigation(scrollRef);
614
+
615
+ // Breakout width based on count
616
+ const count = matchingPaths.length;
617
+ const breakoutClass = count >= 4
618
+ ? 'w-[96vw] ml-[calc(-48vw+50%)]'
619
+ : count >= 2
620
+ ? 'w-[75vw] ml-[calc(-37.5vw+50%)]'
621
+ : '';
622
+
623
+ return (
624
+ <div className={`my-6 ${breakoutClass}`}>
625
+ <div ref={scrollRef} className="flex gap-4 overflow-x-auto overscroll-x-contain pb-2">
626
+ {matchingPaths.map((filePath) => (
627
+ <div key={filePath} className="flex-none w-[280px]">
628
+ <SingleParameterTable
629
+ path={filePath}
630
+ keys={keys}
631
+ label={filePath.split('/').pop() || filePath}
632
+ withMargin={false}
633
+ />
634
+ </div>
635
+ ))}
636
+ </div>
637
+ </div>
638
+ );
639
+ }
@@ -1,4 +1,5 @@
1
1
  import { useParams } from "react-router-dom";
2
+ import { minimatch } from "minimatch";
2
3
  import {
3
4
  type PostEntry,
4
5
  directoryToPostEntries,
@@ -11,6 +12,11 @@ import Loading from "./loading";
11
12
  import { PostListItem } from "./post-list-item";
12
13
  import veslxConfig from "virtual:veslx-config";
13
14
 
15
+ interface PostListProps {
16
+ /** Glob patterns to filter posts by name (e.g., ["01-*", "getting-*"]) */
17
+ globs?: string[] | null;
18
+ }
19
+
14
20
  // Helper to format name for display (e.g., "01-getting-started" → "Getting Started")
15
21
  function formatName(name: string): string {
16
22
  return name
@@ -36,7 +42,7 @@ function getLinkPath(post: PostEntry): string {
36
42
  }
37
43
  }
38
44
 
39
- export function PostList() {
45
+ export function PostList({ globs = null }: PostListProps) {
40
46
  const { "*": path = "." } = useParams();
41
47
 
42
48
  const { directory, loading, error } = useDirectory(path)
@@ -72,6 +78,13 @@ export function PostList() {
72
78
  // Filter out hidden and draft posts
73
79
  posts = filterVisiblePosts(posts);
74
80
 
81
+ // Filter by glob patterns if provided
82
+ if (globs && globs.length > 0) {
83
+ posts = posts.filter(post =>
84
+ globs.some(pattern => minimatch(post.name, pattern, { matchBase: true }))
85
+ );
86
+ }
87
+
75
88
  if (posts.length === 0) {
76
89
  return (
77
90
  <div className="py-24 text-center">