@lad-sapienza/scms-core 0.1.0-alpha.0

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 (64) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +73 -0
  3. package/components/BSNavbar.tsx +264 -0
  4. package/components/DataTb/DataTb.astro +33 -0
  5. package/components/DataTb/DataTb.tsx +340 -0
  6. package/components/DataTb/DataTbMdx.tsx +75 -0
  7. package/components/DataTb/README.md +275 -0
  8. package/components/DataTb/index.ts +36 -0
  9. package/components/DataTb/sources/ApiSource.tsx +89 -0
  10. package/components/DataTb/sources/CsvSource.tsx +69 -0
  11. package/components/DataTb/sources/DirectusSource.tsx +98 -0
  12. package/components/DataTb/sources/JsonSource.tsx +65 -0
  13. package/components/DataTb/sources/index.ts +17 -0
  14. package/components/DataTb/types.ts +215 -0
  15. package/components/DataTb/utils.ts +186 -0
  16. package/components/Gallery/Gallery.astro +65 -0
  17. package/components/Gallery/Gallery.tsx +136 -0
  18. package/components/Gallery/README.md +118 -0
  19. package/components/Gallery/__tests__/galleryUtils.test.ts +178 -0
  20. package/components/Gallery/galleryUtils.ts +186 -0
  21. package/components/Gallery/index.ts +7 -0
  22. package/components/Gallery/react-photoswipe-gallery.d.ts +26 -0
  23. package/components/Gallery/types.ts +46 -0
  24. package/components/Gallery/virtual-gallery.d.ts +15 -0
  25. package/components/Map/LayerControl.tsx +368 -0
  26. package/components/Map/Map.tsx +735 -0
  27. package/components/Map/MapMdx.tsx +37 -0
  28. package/components/Map/RasterLayerLibre.tsx +33 -0
  29. package/components/Map/Search/SearchOperators.ts +31 -0
  30. package/components/Map/Search/SearchUI.tsx +75 -0
  31. package/components/Map/Search/SearchUIAdvanced.tsx +289 -0
  32. package/components/Map/Search/SearchUISimple.tsx +115 -0
  33. package/components/Map/Search/index.ts +7 -0
  34. package/components/Map/defaultBasemaps.ts +88 -0
  35. package/components/Map/index.ts +6 -0
  36. package/components/Map/types.ts +179 -0
  37. package/components/Map/utils.ts +367 -0
  38. package/components/README.md +62 -0
  39. package/components/Record/Field.tsx +35 -0
  40. package/components/Record/Image.tsx +114 -0
  41. package/components/Record/RecordFetcher.tsx +127 -0
  42. package/components/Record/context.tsx +14 -0
  43. package/components/Record/index.ts +11 -0
  44. package/components/Record/useRecordFetcher.ts +104 -0
  45. package/components/Record/utils.ts +13 -0
  46. package/components/SEO/SEO.astro +132 -0
  47. package/components/TableOfContents/TableOfContents.astro +86 -0
  48. package/components/ZoteroGeoViewer/README.md +195 -0
  49. package/components/ZoteroGeoViewer/ZoteroGeoViewer.astro +26 -0
  50. package/components/ZoteroGeoViewer/ZoteroGeoViewer.tsx +483 -0
  51. package/components/ZoteroGeoViewer/ZoteroRecordsPreview.tsx +185 -0
  52. package/components/ZoteroGeoViewer/index.ts +16 -0
  53. package/components/ZoteroGeoViewer/types.ts +123 -0
  54. package/index.ts +26 -0
  55. package/integrations/__tests__/galleryIntegration.test.ts +70 -0
  56. package/integrations/contentAssetsIntegration.ts +132 -0
  57. package/integrations/directusLoader.ts +98 -0
  58. package/integrations/galleryIntegration.ts +78 -0
  59. package/package.json +56 -0
  60. package/scms.ts +95 -0
  61. package/utils/content.ts +88 -0
  62. package/utils/data-fetcher.ts +128 -0
  63. package/utils/directus-config.ts +160 -0
  64. package/utils/record-fetcher.ts +132 -0
@@ -0,0 +1,186 @@
1
+ /**
2
+ * DataTb Utility Functions
3
+ *
4
+ * Helper functions for data transformation, column generation, and formatting
5
+ */
6
+
7
+ import type { ColumnDef } from '@tanstack/react-table';
8
+ import type { DataRow, ColumnConfig } from './types';
9
+
10
+ /**
11
+ * Auto-detect columns from data
12
+ */
13
+ export function autoDetectColumns(data: DataRow[]): ColumnConfig[] {
14
+ if (!data || data.length === 0) return [];
15
+
16
+ const firstRow = data[0];
17
+ const keys = Object.keys(firstRow);
18
+
19
+ return keys.map(key => ({
20
+ key,
21
+ header: formatHeader(key),
22
+ sortable: true,
23
+ }));
24
+ }
25
+
26
+ /**
27
+ * Convert snake_case or camelCase to Title Case
28
+ */
29
+ export function formatHeader(key: string): string {
30
+ return key
31
+ .replace(/([A-Z])/g, ' $1') // camelCase to words
32
+ .replace(/_/g, ' ') // snake_case to words
33
+ .replace(/\b\w/g, (l) => l.toUpperCase()) // capitalize first letter
34
+ .trim();
35
+ }
36
+
37
+ /**
38
+ * Format value based on format type
39
+ */
40
+ export function formatValue(value: any, format?: string): string {
41
+ if (value === null || value === undefined) return '';
42
+
43
+ switch (format) {
44
+ case 'date':
45
+ return formatDate(value);
46
+ case 'number':
47
+ return formatNumber(value);
48
+ case 'currency':
49
+ return formatCurrency(value);
50
+ case 'percent':
51
+ return formatPercent(value);
52
+ default:
53
+ return String(value);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Format date value
59
+ */
60
+ export function formatDate(value: any): string {
61
+ try {
62
+ if (!value) return '';
63
+ const date = new Date(value);
64
+ if (isNaN(date.getTime())) return String(value);
65
+
66
+ return new Intl.DateTimeFormat('en-US', {
67
+ year: 'numeric',
68
+ month: 'short',
69
+ day: 'numeric',
70
+ }).format(date);
71
+ } catch {
72
+ return String(value);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Format number value
78
+ */
79
+ export function formatNumber(value: any): string {
80
+ const num = Number(value);
81
+ if (isNaN(num)) return String(value);
82
+
83
+ return new Intl.NumberFormat('en-US').format(num);
84
+ }
85
+
86
+ /**
87
+ * Format currency value (USD by default)
88
+ */
89
+ export function formatCurrency(value: any, currency: string = 'USD'): string {
90
+ const num = Number(value);
91
+ if (isNaN(num)) return String(value);
92
+
93
+ return new Intl.NumberFormat('en-US', {
94
+ style: 'currency',
95
+ currency,
96
+ }).format(num);
97
+ }
98
+
99
+ /**
100
+ * Format percentage value
101
+ */
102
+ export function formatPercent(value: any): string {
103
+ const num = Number(value);
104
+ if (isNaN(num)) return String(value);
105
+
106
+ return new Intl.NumberFormat('en-US', {
107
+ style: 'percent',
108
+ minimumFractionDigits: 0,
109
+ maximumFractionDigits: 2,
110
+ }).format(num);
111
+ }
112
+
113
+ /**
114
+ * Convert ColumnConfig to TanStack ColumnDef
115
+ */
116
+ export function columnConfigToColumnDef(config: ColumnConfig): ColumnDef<DataRow> {
117
+ return {
118
+ id: config.key,
119
+ accessorKey: config.key,
120
+ header: config.header,
121
+ enableSorting: config.sortable ?? true,
122
+ meta: { width: config.width },
123
+ cell: (info) => {
124
+ const value = info.getValue();
125
+ const row = info.row.original;
126
+
127
+ // Use custom render if provided
128
+ if (config.render) {
129
+ return config.render(value, row);
130
+ }
131
+
132
+ // Use labels map if provided
133
+ if (config.labels) {
134
+ const key = String(value);
135
+ if (config.labels[key]) {
136
+ return config.labels[key];
137
+ }
138
+ }
139
+
140
+ // Apply format if specified
141
+ return formatValue(value, config.format);
142
+ },
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Merge auto-detected columns with user-provided overrides
148
+ */
149
+ export function mergeColumns(
150
+ autoColumns: ColumnConfig[],
151
+ userColumns?: ColumnConfig[]
152
+ ): ColumnConfig[] {
153
+ // If no user columns, use all auto-detected columns
154
+ if (!userColumns || userColumns.length === 0) {
155
+ return autoColumns;
156
+ }
157
+
158
+ // Exclusive Mode:
159
+ // Return ONLY user columns, but enrich them with auto-detected defaults (like headers) if available
160
+ return userColumns.map(userCol => {
161
+ const autoCol = autoColumns.find(ac => ac.key === userCol.key);
162
+ // If we found a match, merge defaults from autoCol, but let userCol override
163
+ if (autoCol) {
164
+ return { ...autoCol, ...userCol };
165
+ }
166
+ // If no match (computed column or non-existent key), return userCol as is
167
+ return userCol;
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Global filter function for searchable tables
173
+ */
174
+ export function globalFilterFn(
175
+ row: any,
176
+ columnId: string,
177
+ filterValue: string
178
+ ): boolean {
179
+ const value = row.getValue(columnId);
180
+
181
+ if (value === null || value === undefined) return false;
182
+
183
+ return String(value)
184
+ .toLowerCase()
185
+ .includes(String(filterValue).toLowerCase());
186
+ }
@@ -0,0 +1,65 @@
1
+ ---
2
+ /**
3
+ * Gallery Component
4
+ *
5
+ * Renders a responsive image grid with a PhotoSwipe lightbox. Usable from
6
+ * both .astro files and MDX content bodies (see core/integrations/galleryIntegration.ts
7
+ * for how auto-loading works without each caller needing its own glob).
8
+ *
9
+ * <Gallery /> colocated: ./gallery/ next to this page/content file
10
+ * <Gallery name="scavi-2024" /> shared: usr/galleries/scavi-2024/
11
+ * <Gallery images={[...]} /> explicit: pass images directly (remote, custom order, ...)
12
+ */
13
+ import { Gallery as GalleryClient } from './Gallery';
14
+ import { getColocatedGalleryImages, getSharedGalleryImages, sortImages } from './galleryUtils';
15
+ import type { GalleryProps } from './types';
16
+
17
+ const { name, images: explicitImages, reverseSorting = false, columns = { min: 200, max: 1 }, className } = Astro.props as GalleryProps;
18
+
19
+ // Auto-loaded images (colocated/shared) are always sorted by filename —
20
+ // DESC by default, ASC when reverseSorting is set. Explicit `images` are
21
+ // left in exactly the order the caller gave them, since a custom order is
22
+ // one of the reasons to use that escape hatch in the first place.
23
+ let finalImages;
24
+
25
+ if (explicitImages && explicitImages.length > 0) {
26
+ finalImages = explicitImages;
27
+ } else if (name) {
28
+ finalImages = sortImages(getSharedGalleryImages(name), reverseSorting);
29
+ } else {
30
+ finalImages = sortImages(getColocatedGalleryImages(Astro.url.pathname), reverseSorting);
31
+ }
32
+ ---
33
+
34
+ {finalImages.length > 0 ? (
35
+ <GalleryClient
36
+ images={finalImages}
37
+ columns={columns}
38
+ className={className}
39
+ client:idle
40
+ />
41
+ ) : (
42
+ <p class="gallery-empty">
43
+ No images found{name && <> for gallery "<code>{name}</code>"</>}.
44
+ {!name && !explicitImages && (
45
+ <> Add images to a <code>gallery/</code> folder next to this page.</>
46
+ )}
47
+ </p>
48
+ )}
49
+
50
+ <style>
51
+ .gallery-empty {
52
+ padding: 2rem;
53
+ background: #f5f5f5;
54
+ border-radius: 8px;
55
+ text-align: center;
56
+ color: #666;
57
+ }
58
+
59
+ .gallery-empty code {
60
+ background: #e0e0e0;
61
+ padding: 2px 6px;
62
+ border-radius: 3px;
63
+ font-family: monospace;
64
+ }
65
+ </style>
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Gallery Component
3
+ *
4
+ * React component for displaying image gallery with PhotoSwipe lightbox
5
+ */
6
+
7
+
8
+ import { Gallery as PhotoSwipeGallery, Item } from 'react-photoswipe-gallery';
9
+ import type { GalleryImage } from './types';
10
+ import 'photoswipe/dist/photoswipe.css';
11
+
12
+ interface Props {
13
+ images?: GalleryImage[];
14
+ columns?: {
15
+ min?: number;
16
+ max?: number;
17
+ };
18
+ className?: string;
19
+ }
20
+
21
+ const captionStyle = `
22
+ .pswp__custom-caption {
23
+ background: rgba(0, 0, 0, 0.75);
24
+ font-size: 16px;
25
+ color: #fff;
26
+ width: 100%;
27
+ max-width: 100%;
28
+ padding: 12px 16px;
29
+ border-radius: 0;
30
+ position: absolute;
31
+ left: 0;
32
+ bottom: 0;
33
+ text-align: center;
34
+ }
35
+ .pswp__custom-caption a {
36
+ color: #fff;
37
+ text-decoration: underline;
38
+ }
39
+ `;
40
+
41
+ export function Gallery({
42
+ images = [],
43
+ columns = { min: 200, max: 1 },
44
+ className = '',
45
+ }: Props) {
46
+ if (images.length === 0) {
47
+ return null;
48
+ }
49
+
50
+ // Ensure defaults for columns
51
+ const minWidth = columns?.min ?? 200;
52
+ const maxWidth = columns?.max ?? 1;
53
+
54
+ return (
55
+ <>
56
+ <style>{captionStyle}</style>
57
+ <PhotoSwipeGallery
58
+ options={{
59
+ showHideAnimationType: 'fade',
60
+ showAnimationDuration: 0,
61
+ hideAnimationDuration: 0,
62
+ }}
63
+ uiElements={[
64
+ {
65
+ name: 'custom-caption',
66
+ order: 9,
67
+ isButton: false,
68
+ appendTo: 'root',
69
+ html: '',
70
+ onInit: (el: any, pswpInstance: any) => {
71
+ el.classList.add('pswp__custom-caption');
72
+
73
+ pswpInstance.on('change', () => {
74
+ const currSlideElement = pswpInstance.currSlide?.data?.element;
75
+ // Captions are plain text (from captions.json or a filename) — use
76
+ // textContent, not innerHTML, so caption text can never be parsed as markup.
77
+ el.textContent = currSlideElement?.getAttribute('data-caption') || '';
78
+ });
79
+ },
80
+ },
81
+ ]}
82
+ >
83
+ <div
84
+ className={`gallery-grid ${className}`}
85
+ style={{
86
+ display: 'grid',
87
+ gridTemplateColumns: `repeat(auto-fill, minmax(${minWidth}px, ${maxWidth}fr))`,
88
+ gap: '8px',
89
+ padding: 0,
90
+ }}
91
+ >
92
+ {images.map((image, index) => (
93
+ <Item
94
+ key={index}
95
+ original={image.src}
96
+ thumbnail={image.thumb}
97
+ width={image.width}
98
+ height={image.height}
99
+ caption={image.caption || image.alt}
100
+ >
101
+ {({ ref, open }: any) => (
102
+ <div
103
+ ref={ref}
104
+ onClick={open}
105
+ onKeyDown={(e) => {
106
+ if (e.key === 'Enter' || e.key === ' ') {
107
+ e.preventDefault();
108
+ open();
109
+ }
110
+ }}
111
+ role="button"
112
+ tabIndex={0}
113
+ style={{ cursor: 'pointer', overflow: 'hidden', borderRadius: '4px' }}
114
+ data-caption={image.caption || image.alt}
115
+ aria-label={`Open image: ${image.alt}`}
116
+ >
117
+ <img
118
+ src={image.thumb}
119
+ alt={image.alt}
120
+ style={{
121
+ width: '100%',
122
+ height: '100%',
123
+ objectFit: 'cover',
124
+ aspectRatio: '1',
125
+ display: 'block',
126
+ }}
127
+ />
128
+ </div>
129
+ )}
130
+ </Item>
131
+ ))}
132
+ </div>
133
+ </PhotoSwipeGallery>
134
+ </>
135
+ );
136
+ }
@@ -0,0 +1,118 @@
1
+ # Gallery Component
2
+
3
+ A responsive image gallery component with PhotoSwipe lightbox integration. One component, usable the same way from `.astro` files and MDX content — no `import.meta.glob` needed in your own page or content file.
4
+
5
+ ## Features
6
+
7
+ - 📸 **PhotoSwipe Lightbox**: Full-screen image viewing with zoom and navigation
8
+ - 🎨 **Responsive Grid**: Auto-adjusting columns based on screen size
9
+ - 🔄 **Auto-loading**: automatically discovers images in a `gallery/` folder colocated with the current page/content file
10
+ - 🔗 **Shared galleries**: reference one set of images by name from `usr/galleries/<name>/`
11
+ - 🔍 **Custom Captions**: via `captions.json`, or auto-generated from filenames
12
+ - ⌨️ **Keyboard Navigation**: Arrow keys and ESC support
13
+ - ♿ **Accessible**: Proper ARIA labels and keyboard support
14
+
15
+ ## How auto-loading works
16
+
17
+ `Gallery.astro` doesn't glob its own files — a separate integration
18
+ (`core/integrations/galleryIntegration.ts`, bundled into `core/scms.ts` and wired into `astro.config.mjs`)
19
+ generates a Vite virtual module (`virtual:scms/galleries`) containing the
20
+ `import.meta.glob()` calls, at project build time. `Gallery.astro` just reads
21
+ from that virtual module and matches by URL path. This exists so the component
22
+ can move into an npm package later without losing auto-loading (a component
23
+ shipped in `node_modules/` can't statically glob a consumer project's files;
24
+ the integration generating the glob call at the consumer's own build time
25
+ can). See `core/integrations/galleryIntegration.ts`'s own doc comment for
26
+ details, and `core/components/Gallery/galleryUtils.ts` for the matching logic.
27
+
28
+ ## Usage
29
+
30
+ ### Colocated (default) — auto-loads from a sibling `gallery/` folder
31
+
32
+ ```
33
+ usr/content/blog/my-post/
34
+ index.mdx
35
+ gallery/
36
+ photo1.jpg
37
+ photo2.jpg
38
+ captions.json ← optional
39
+ ```
40
+
41
+ ```mdx
42
+ import { Gallery } from '@lad-sapienza/scms-core/components/Gallery';
43
+
44
+ <Gallery />
45
+ ```
46
+
47
+ Matching is based on the current page's URL path against the folder
48
+ structure on disk. **Known limitation**: if a content collection entry
49
+ overrides its slug, or the site is deployed under a non-root `base`, the URL
50
+ path can diverge from the on-disk folder path and the match silently finds
51
+ nothing. Use `name` (below) or `images` as a workaround in that case.
52
+
53
+ ### Shared — referenced by name from any page
54
+
55
+ ```
56
+ usr/galleries/
57
+ scavi-2024/
58
+ photo1.jpg
59
+ photo2.jpg
60
+ captions.json ← optional
61
+ ```
62
+
63
+ ```mdx
64
+ <Gallery name="scavi-2024" />
65
+ ```
66
+
67
+ ### Explicit — pass images directly
68
+
69
+ Escape hatch for remote images, a Directus-backed source, or a custom order.
70
+ Takes precedence over both `name` and auto-loading.
71
+
72
+ ```jsx
73
+ <Gallery
74
+ images={[
75
+ { src: '/photos/img1.jpg', thumb: '/photos/img1.jpg', width: 1200, height: 800, alt: 'Description', caption: 'My custom caption' },
76
+ ]}
77
+ />
78
+ ```
79
+
80
+ ## Custom Captions
81
+
82
+ By default, captions are generated from filenames (e.g., `my-photo.jpg` → "My Photo"). Place a `captions.json` file in the same `gallery/` folder (colocated) or shared-gallery folder to override them:
83
+
84
+ ```json
85
+ {
86
+ "photo1.jpg": "A beautiful sunset over the mountains",
87
+ "photo2.jpg": "Morning coffee with a view"
88
+ }
89
+ ```
90
+
91
+ Keys can be filenames with or without extensions, and can use a different
92
+ resolution suffix than the actual file (`_1280`, `_1920`, etc.) — matching is
93
+ fuzzy on the base filename. A malformed `captions.json` (not a flat
94
+ string-to-string map) is ignored with a console warning rather than breaking
95
+ the build.
96
+
97
+ ## Props API
98
+
99
+ | Prop | Type | Default | Description |
100
+ |---|---|---|---|
101
+ | `name` | `string` | — | Load a shared gallery from `usr/galleries/<name>/` |
102
+ | `images` | `GalleryImage[]` | — | Explicit images — takes precedence over `name` and auto-loading |
103
+ | `reverseSorting` | `boolean` | `false` | Reverse the default alphabetical sort |
104
+ | `columns` | `{ min?: number, max?: number }` | `{ min: 200, max: 1 }` | `min` = minimum item width in px; `max` = maximum column count |
105
+ | `className` | `string` | — | Additional CSS classes |
106
+
107
+ ### GalleryImage Type
108
+
109
+ ```typescript
110
+ interface GalleryImage {
111
+ src: string; // Full-size image URL
112
+ thumb: string; // Thumbnail URL
113
+ width: number;
114
+ height: number;
115
+ alt: string;
116
+ caption?: string;
117
+ }
118
+ ```
@@ -0,0 +1,178 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ const manifest = {
4
+ pagesImages: {
5
+ '/usr/pages/my-page/gallery/photo1.jpg': { default: { src: '/_astro/photo1.abc123.jpg', width: 1200, height: 800 } },
6
+ },
7
+ contentImages: {
8
+ '/usr/content/blog/my-article/gallery/photo2.jpg': { default: { src: '/_astro/photo2.def456.jpg', width: 1000, height: 600 } },
9
+ '/usr/content/blog/other-article/gallery/photo3.jpg': { default: { src: '/_astro/photo3.ghi789.jpg', width: 900, height: 500 } },
10
+ },
11
+ sharedImages: {
12
+ '/usr/galleries/scavi-2024/site-a.jpg': { default: { src: '/_astro/site-a.jkl000.jpg', width: 800, height: 600 } },
13
+ '/usr/galleries/scavi-2024/site-b_1920.jpg': { default: { src: '/_astro/site-b_1920.mno111.jpg', width: 800, height: 600 } },
14
+ '/usr/galleries/other-name/site-c.jpg': { default: { src: '/_astro/site-c.pqr222.jpg', width: 800, height: 600 } },
15
+ },
16
+ pagesCaptions: {},
17
+ contentCaptions: {
18
+ '/usr/content/blog/my-article/gallery/captions.json': { default: { 'photo2.jpg': 'A custom caption' } },
19
+ },
20
+ sharedCaptions: {
21
+ '/usr/galleries/scavi-2024/captions.json': {
22
+ default: {
23
+ 'site-a.jpg': 'Exact match caption',
24
+ // Deliberately a different resolution suffix than the real file
25
+ // (site-b_1920.jpg vs the real site-b_1920.jpg above uses the same
26
+ // base name so this also verifies base-filename fuzzy matching).
27
+ 'site-b_1280.jpg': 'Fuzzy match caption',
28
+ },
29
+ },
30
+ },
31
+ };
32
+
33
+ vi.mock('virtual:scms/galleries', () => manifest);
34
+
35
+ const {
36
+ getColocatedGalleryImages,
37
+ getColocatedCaptions,
38
+ getSharedGalleryImages,
39
+ getSharedCaptions,
40
+ sortImages,
41
+ formatFilename,
42
+ getFileName,
43
+ } = await import('../galleryUtils');
44
+
45
+ describe('getColocatedGalleryImages', () => {
46
+ it('matches a page-colocated gallery by URL path', () => {
47
+ const images = getColocatedGalleryImages('/my-page/');
48
+ expect(images).toHaveLength(1);
49
+ expect(images[0].src).toBe('/_astro/photo1.abc123.jpg');
50
+ expect(images[0].width).toBe(1200);
51
+ });
52
+
53
+ it('matches a content-colocated gallery by URL path', () => {
54
+ const images = getColocatedGalleryImages('/blog/my-article');
55
+ expect(images).toHaveLength(1);
56
+ expect(images[0].src).toBe('/_astro/photo2.def456.jpg');
57
+ });
58
+
59
+ it('handles a trailing slash the same as without one', () => {
60
+ const withSlash = getColocatedGalleryImages('/blog/my-article/');
61
+ const withoutSlash = getColocatedGalleryImages('/blog/my-article');
62
+ expect(withSlash).toEqual(withoutSlash);
63
+ });
64
+
65
+ it('returns an empty array when no gallery matches the path', () => {
66
+ expect(getColocatedGalleryImages('/no/such/page')).toEqual([]);
67
+ });
68
+
69
+ it('derives alt/caption from the filename when no captions.json applies', () => {
70
+ const [image] = getColocatedGalleryImages('/my-page/');
71
+ expect(image.alt).toBe('Photo1');
72
+ expect(image.caption).toBe('Photo1');
73
+ });
74
+
75
+ it('applies a matching captions.json caption instead of the filename fallback', () => {
76
+ const [image] = getColocatedGalleryImages('/blog/my-article');
77
+ expect(image.caption).toBe('A custom caption');
78
+ expect(image.alt).toBe('A custom caption');
79
+ });
80
+ });
81
+
82
+ describe('getColocatedCaptions', () => {
83
+ it('returns the parsed captions for a matching path', () => {
84
+ expect(getColocatedCaptions('/blog/my-article')).toEqual({ 'photo2.jpg': 'A custom caption' });
85
+ });
86
+
87
+ it('returns undefined when there is no captions.json for the path', () => {
88
+ expect(getColocatedCaptions('/my-page/')).toBeUndefined();
89
+ });
90
+ });
91
+
92
+ describe('getSharedGalleryImages', () => {
93
+ it('returns only images under the matching shared gallery name', () => {
94
+ const images = getSharedGalleryImages('scavi-2024');
95
+ expect(images).toHaveLength(2);
96
+ expect(images.map((i) => i.src).sort()).toEqual(['/_astro/site-a.jkl000.jpg', '/_astro/site-b_1920.mno111.jpg']);
97
+ });
98
+
99
+ it('returns an empty array for an unknown gallery name', () => {
100
+ expect(getSharedGalleryImages('does-not-exist')).toEqual([]);
101
+ });
102
+
103
+ it('applies an exact-filename caption match', () => {
104
+ const images = getSharedGalleryImages('scavi-2024');
105
+ const siteA = images.find((i) => i.src.includes('site-a'));
106
+ expect(siteA?.caption).toBe('Exact match caption');
107
+ });
108
+
109
+ it('fuzzy-matches a caption across a different resolution suffix than the real file', () => {
110
+ const images = getSharedGalleryImages('scavi-2024');
111
+ const siteB = images.find((i) => i.src.includes('site-b'));
112
+ expect(siteB?.caption).toBe('Fuzzy match caption');
113
+ });
114
+
115
+ it('falls back to the filename when no gallery-wide captions.json exists', () => {
116
+ const images = getSharedGalleryImages('other-name');
117
+ expect(images[0].caption).toBe('Site C');
118
+ });
119
+ });
120
+
121
+ describe('getSharedCaptions', () => {
122
+ let warnSpy: ReturnType<typeof vi.spyOn>;
123
+
124
+ beforeEach(() => {
125
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
126
+ });
127
+
128
+ afterEach(() => {
129
+ warnSpy.mockRestore();
130
+ });
131
+
132
+ it('logs a warning and returns undefined for a malformed captions.json', () => {
133
+ const result = parseMalformedFixture();
134
+ expect(result).toBeUndefined();
135
+ expect(warnSpy).toHaveBeenCalledWith(
136
+ expect.stringContaining('Invalid captions.json'),
137
+ expect.anything()
138
+ );
139
+ });
140
+
141
+ it('returns undefined for an unknown gallery name, without warning', () => {
142
+ expect(getSharedCaptions('does-not-exist')).toBeUndefined();
143
+ expect(warnSpy).not.toHaveBeenCalled();
144
+ });
145
+
146
+ function parseMalformedFixture() {
147
+ // Reuses the real function against a manifest entry shaped like a
148
+ // malformed captions.json (nested object instead of Record<string,string>).
149
+ (manifest.sharedCaptions as any)['/usr/galleries/broken/captions.json'] = { default: { nested: { not: 'a string' } } };
150
+ return getSharedCaptions('broken');
151
+ }
152
+ });
153
+
154
+ describe('sortImages', () => {
155
+ const images = [
156
+ { src: 'a.jpg', thumb: '', width: 1, height: 1, alt: '', caption: '' },
157
+ { src: 'c.jpg', thumb: '', width: 1, height: 1, alt: '', caption: '' },
158
+ { src: 'b.jpg', thumb: '', width: 1, height: 1, alt: '', caption: '' },
159
+ ];
160
+
161
+ it('sorts descending by filename by default', () => {
162
+ expect(sortImages(images).map((i) => i.src)).toEqual(['c.jpg', 'b.jpg', 'a.jpg']);
163
+ });
164
+
165
+ it('sorts ascending when reverse is true', () => {
166
+ expect(sortImages(images, true).map((i) => i.src)).toEqual(['a.jpg', 'b.jpg', 'c.jpg']);
167
+ });
168
+ });
169
+
170
+ describe('formatFilename / getFileName', () => {
171
+ it('extracts a filename without its extension', () => {
172
+ expect(getFileName('/a/b/my-photo.jpg')).toBe('my-photo');
173
+ });
174
+
175
+ it('formats a hyphenated filename into title case', () => {
176
+ expect(formatFilename('my-photo-file')).toBe('My Photo File');
177
+ });
178
+ });