@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,185 @@
1
+ import React, { useState, useEffect } from 'react';
2
+
3
+ // Helper to strip two outermost divs from a HTML string
4
+ function stripWrappingDivs(html: string): string {
5
+ if (typeof html !== "string") return html;
6
+ // Remove first <div>...</div>
7
+ html = html.replace(/^\s*<div[^>]*>([\s\S]*)<\/div>\s*$/, "$1");
8
+ // Remove next <div>...</div>
9
+ html = html.replace(/^\s*<div[^>]*>([\s\S]*)<\/div>\s*$/, "$1");
10
+ return html;
11
+ }
12
+
13
+ interface ZoteroItem {
14
+ key: string;
15
+ bib?: string;
16
+ data?: {
17
+ key?: string;
18
+ title?: string;
19
+ };
20
+ }
21
+
22
+ interface ZoteroTag {
23
+ main: string;
24
+ alternatives?: string[];
25
+ }
26
+
27
+ interface ZoteroRecordsPreviewProps {
28
+ groupId: number;
29
+ tag: ZoteroTag | null;
30
+ /** Maximum number of records to fetch/display (default: 1000) */
31
+ maxItems?: number;
32
+ }
33
+
34
+ export function ZoteroRecordsPreview({ groupId, tag, maxItems = 1000 }: ZoteroRecordsPreviewProps) {
35
+ const [records, setRecords] = useState<ZoteroItem[]>([]);
36
+ const [loading, setLoading] = useState(false);
37
+ const [error, setError] = useState<string | null>(null);
38
+
39
+ useEffect(() => {
40
+ if (!tag || !tag.main) {
41
+ setRecords([]);
42
+ setError(null);
43
+ setLoading(false);
44
+ return;
45
+ }
46
+
47
+ let cancelled = false;
48
+
49
+ async function fetchAllRecords() {
50
+ if (!tag || !tag.main) return; // Additional null check for TypeScript
51
+
52
+ setLoading(true);
53
+ setError(null);
54
+ setRecords([]);
55
+
56
+ const allTags = [
57
+ tag.main,
58
+ ...(tag.alternatives || [])
59
+ ].filter(Boolean);
60
+ const tagsQuery = allTags.map(t => `@${t}`).join(' || ');
61
+
62
+ const baseUrl = `https://api.zotero.org/groups/${groupId}/items`;
63
+ const limit = 100;
64
+ const buildUrl = (start = 0) =>
65
+ `${baseUrl}?sort=date&include=bib&tag=${encodeURIComponent(tagsQuery)}&format=json&limit=${limit}&start=${start}`;
66
+
67
+ try {
68
+ let all: ZoteroItem[] = [];
69
+ let start = 0;
70
+ let total: number | null = null;
71
+ let hasNext = true;
72
+
73
+ while (hasNext && all.length < maxItems) {
74
+ const resp = await fetch(buildUrl(start), {
75
+ headers: { Accept: "application/json" },
76
+ });
77
+ if (!resp.ok)
78
+ throw new Error(`Zotero API error: ${resp.status} ${resp.statusText}`);
79
+
80
+ if (total == null) {
81
+ const totalHeader = resp.headers.get("Total-Results") || resp.headers.get("total-results");
82
+ if (totalHeader) {
83
+ const n = parseInt(totalHeader, 10);
84
+ if (!Number.isNaN(n)) total = n;
85
+ }
86
+ }
87
+
88
+ const page = await resp.json();
89
+ if (cancelled) return;
90
+
91
+ all = all.concat(page);
92
+
93
+ if (total != null) {
94
+ hasNext = all.length < total;
95
+ } else {
96
+ const link = resp.headers.get("Link") || resp.headers.get("link");
97
+ if (link && /rel="next"/i.test(link)) {
98
+ hasNext = true;
99
+ } else {
100
+ hasNext = page.length === limit;
101
+ }
102
+ }
103
+ start += page.length;
104
+
105
+ // Update progressively so the UI shows results while loading
106
+ setRecords(all.slice(0, maxItems));
107
+ }
108
+
109
+ if (!cancelled) setRecords(all.slice(0, maxItems));
110
+ } catch (err) {
111
+ if (!cancelled) setError(err instanceof Error ? err.message : "Error fetching records");
112
+ } finally {
113
+ if (!cancelled) setLoading(false);
114
+ }
115
+ }
116
+
117
+ fetchAllRecords();
118
+ return () => {
119
+ cancelled = true;
120
+ };
121
+ }, [groupId, tag, maxItems]);
122
+
123
+ if (!tag || !tag.main) return null;
124
+
125
+ return (
126
+ <div className="mt-3">
127
+ <h4 className="h5 fw-semibold">
128
+ Zotero records for <span className="text-primary">{tag.main}</span>
129
+ {tag.alternatives && tag.alternatives.length > 0 && (
130
+ <small className="text-secondary ms-2">{' '}
131
+ (including: {tag.alternatives.join(", ")})
132
+ </small>
133
+ )}
134
+ </h4>
135
+ {loading && <div className="text-primary">Loading records...</div>}
136
+ {error && <div className="text-danger">Error: {error}</div>}
137
+ {!loading && records && records.length > 0 && (
138
+ <ol className="mt-3">
139
+ {records.map(item => {
140
+ const key = item.key || item.data?.key;
141
+ const zoteroLink = key
142
+ ? `https://www.zotero.org/groups/${groupId}/items/${key}`
143
+ : null;
144
+ return (
145
+ <li
146
+ key={key}
147
+ className="mb-2 ps-3"
148
+ style={{ }}
149
+ >
150
+ {item.bib ? (
151
+ <span
152
+ dangerouslySetInnerHTML={{
153
+ __html: stripWrappingDivs(item.bib),
154
+ }}
155
+ />
156
+ ) : item.data && item.data.title ? (
157
+ item.data.title
158
+ ) : (
159
+ JSON.stringify(item)
160
+ )}
161
+ {zoteroLink && (
162
+ <React.Fragment>
163
+ <br />
164
+ <a
165
+ href={zoteroLink}
166
+ target="_blank"
167
+ rel="noopener noreferrer"
168
+ className="small text-primary text-decoration-underline"
169
+ style={{ whiteSpace: "nowrap" }}
170
+ >
171
+ View in the Zotero Library
172
+ </a>
173
+ </React.Fragment>
174
+ )}
175
+ </li>
176
+ );
177
+ })}
178
+ </ol>
179
+ )}
180
+ {!loading && (!records || records.length === 0) && !error && (
181
+ <div className="text-danger">No records found for this tag.</div>
182
+ )}
183
+ </div>
184
+ );
185
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * ZoteroGeoViewer Component
3
+ *
4
+ * Connects to a Zotero group library and visualizes items with geographical coordinates
5
+ * on a map, providing tag-based filtering and different layout options.
6
+ */
7
+
8
+ export { ZoteroGeoViewer } from './ZoteroGeoViewer';
9
+ export { ZoteroRecordsPreview } from './ZoteroRecordsPreview';
10
+ export type {
11
+ ZoteroGeoViewerProps,
12
+ ZoteroItem,
13
+ CoordinateData,
14
+ ZoteroGeoData,
15
+ LayoutType
16
+ } from './types';
@@ -0,0 +1,123 @@
1
+ /**
2
+ * ZoteroGeoViewer Component Types
3
+ */
4
+
5
+ export interface ZoteroItem {
6
+ key: string;
7
+ version: number;
8
+ library: {
9
+ type: string;
10
+ id: number;
11
+ name: string;
12
+ links: {
13
+ alternate: {
14
+ href: string;
15
+ type: string;
16
+ };
17
+ };
18
+ };
19
+ links: {
20
+ self: {
21
+ href: string;
22
+ type: string;
23
+ };
24
+ alternate: {
25
+ href: string;
26
+ type: string;
27
+ };
28
+ };
29
+ meta: {
30
+ parsedDate: string;
31
+ numChildren: number;
32
+ };
33
+ data: {
34
+ key: string;
35
+ version: number;
36
+ itemType: string;
37
+ title: string;
38
+ creators: Array<{
39
+ creatorType: string;
40
+ firstName: string;
41
+ lastName: string;
42
+ }>;
43
+ abstractNote: string;
44
+ publicationTitle: string;
45
+ volume: string;
46
+ issue: string;
47
+ pages: string;
48
+ date: string;
49
+ series: string;
50
+ seriesTitle: string;
51
+ seriesText: string;
52
+ journalAbbreviation: string;
53
+ language: string;
54
+ DOI: string;
55
+ ISSN: string;
56
+ shortTitle: string;
57
+ url: string;
58
+ accessDate: string;
59
+ archive: string;
60
+ archiveLocation: string;
61
+ libraryCatalog: string;
62
+ callNumber: string;
63
+ rights: string;
64
+ extra: string;
65
+ tags: Array<{
66
+ tag: string;
67
+ type?: number;
68
+ }>;
69
+ collections: string[];
70
+ relations: Record<string, any>;
71
+ dateAdded: string;
72
+ dateModified: string;
73
+ };
74
+ }
75
+
76
+ export interface CoordinateData {
77
+ name: string;
78
+ altLabel?: string | null;
79
+ id: number;
80
+ source?: string | null;
81
+ coordinates: [number, number]; // [longitude, latitude]
82
+ }
83
+
84
+ export interface ZoteroGeoData extends GeoJSON.FeatureCollection {
85
+ features: Array<GeoJSON.Feature & {
86
+ properties: CoordinateData & {
87
+ zoteroItems?: ZoteroItem[];
88
+ };
89
+ }>;
90
+ }
91
+
92
+ export type LayoutType =
93
+ | 'vertical'
94
+ | '6x6'
95
+ | '8x4'
96
+ | '4x8'
97
+ | '12x4'
98
+ | 'horizontal';
99
+
100
+ export interface ZoteroGeoViewerProps {
101
+ /** Zotero group ID (required) */
102
+ groupId: number;
103
+
104
+ /** Layout configuration for the display */
105
+ layout?: LayoutType;
106
+
107
+ /** Custom map height */
108
+ mapHeight?: string;
109
+
110
+ /** Custom map center (lng,lat,zoom) */
111
+ mapCenter?: string;
112
+
113
+ /** Enable/disable tag autocomplete */
114
+ tagAutocomplete?: boolean;
115
+
116
+ /** Maximum number of items to display */
117
+ maxItems?: number;
118
+ }
119
+
120
+ export interface ZoteroApiResponse {
121
+ items: ZoteroItem[];
122
+ totalResults: number;
123
+ }
package/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ // Core package exports
2
+ export { Gallery } from './components/Gallery';
3
+ export { default as TableOfContents } from './components/TableOfContents/TableOfContents.astro';
4
+
5
+ // DataTb (recommended table component)
6
+ export { DataTb, CsvSource, JsonSource, DirectusSource, ApiSource } from './components/DataTb';
7
+
8
+ // Map components
9
+ export { Map as MapComponent } from './components/Map';
10
+ export { SearchUI, SearchUISimple, SearchUIAdvanced } from './components/Map/Search';
11
+
12
+ // ZoteroGeoViewer component
13
+ export { ZoteroGeoViewer } from './components/ZoteroGeoViewer';
14
+ export type { ZoteroGeoViewerProps, ZoteroItem, CoordinateData, LayoutType } from './components/ZoteroGeoViewer';
15
+
16
+ // Record helpers
17
+ export { RecordProvider, Field, Image, RecordFetcher, useRecordFetcher, getValueByDotPath } from './components/Record';
18
+ export type { RecordProviderProps, FieldProps, ImageProps, RecordFetcherProps, UseRecordFetcherOptions, UseRecordFetcherResult } from './components/Record';
19
+
20
+ // Utils
21
+ export * from './utils/record-fetcher';
22
+ // Note: content utils not exported to prevent astro:content in client components
23
+ export * from './utils/directus-config';
24
+
25
+ // Types
26
+ export type * from './components/Map/types';
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { galleryIntegration, GALLERY_VIRTUAL_MODULE_ID, buildGalleryVirtualModuleSource } from '../galleryIntegration';
3
+
4
+ function getVitePlugin(options?: Parameters<typeof galleryIntegration>[0]) {
5
+ let capturedConfig: any;
6
+ const updateConfig = vi.fn((config: any) => {
7
+ capturedConfig = config;
8
+ });
9
+
10
+ const integration = galleryIntegration(options);
11
+ const setupHook = integration.hooks?.['astro:config:setup'];
12
+ expect(setupHook).toBeTypeOf('function');
13
+
14
+ // @ts-expect-error - minimal fake hook params, only what the hook uses
15
+ setupHook({ updateConfig });
16
+
17
+ const plugin = capturedConfig.vite.plugins[0];
18
+ return plugin;
19
+ }
20
+
21
+ describe('galleryIntegration', () => {
22
+ it('registers a resolveId/load pair for the virtual module id', () => {
23
+ const plugin = getVitePlugin();
24
+ const resolved = plugin.resolveId(GALLERY_VIRTUAL_MODULE_ID);
25
+ expect(resolved).toBe('\0' + GALLERY_VIRTUAL_MODULE_ID);
26
+ expect(plugin.resolveId('some/other/id')).toBeUndefined();
27
+ });
28
+
29
+ it('generates a virtual module with one static glob per source root, using the default usr/ paths', () => {
30
+ const plugin = getVitePlugin();
31
+ const resolvedId = plugin.resolveId(GALLERY_VIRTUAL_MODULE_ID);
32
+ const source = plugin.load(resolvedId);
33
+
34
+ expect(source).toContain("import.meta.glob('/usr/pages/**/gallery/*.");
35
+ expect(source).toContain("import.meta.glob('/usr/content/**/gallery/*.");
36
+ expect(source).toContain("import.meta.glob('/usr/galleries/*/*.");
37
+ expect(source).toContain("import.meta.glob('/usr/pages/**/gallery/captions.json'");
38
+ expect(source).toContain("import.meta.glob('/usr/content/**/gallery/captions.json'");
39
+ expect(source).toContain("import.meta.glob('/usr/galleries/*/captions.json'");
40
+
41
+ // Every glob call must use { eager: true } — a lazy glob would return
42
+ // dynamic import() functions instead of already-resolved image metadata.
43
+ const globCallCount = (source.match(/import\.meta\.glob/g) || []).length;
44
+ const eagerCount = (source.match(/\{\s*eager:\s*true\s*\}/g) || []).length;
45
+ expect(eagerCount).toBe(globCallCount);
46
+ });
47
+
48
+ it('returns undefined for any id it does not own', () => {
49
+ const plugin = getVitePlugin();
50
+ expect(plugin.load('/some/unrelated/module.js')).toBeUndefined();
51
+ });
52
+
53
+ it('honors custom directory options', () => {
54
+ const plugin = getVitePlugin({ pagesDir: 'src/pages', contentDir: 'src/content', galleriesDir: 'src/galleries' });
55
+ const source = plugin.load(plugin.resolveId(GALLERY_VIRTUAL_MODULE_ID));
56
+
57
+ expect(source).toContain("import.meta.glob('/src/pages/**/gallery/*.");
58
+ expect(source).toContain("import.meta.glob('/src/content/**/gallery/*.");
59
+ expect(source).toContain("import.meta.glob('/src/galleries/*/*.");
60
+ expect(source).not.toContain('/usr/');
61
+ });
62
+ });
63
+
64
+ describe('buildGalleryVirtualModuleSource', () => {
65
+ it('strips leading/trailing slashes from a configured directory before building the glob prefix', () => {
66
+ const source = buildGalleryVirtualModuleSource({ pagesDir: '/src/pages/' });
67
+ expect(source).toContain("import.meta.glob('/src/pages/**/gallery/*.");
68
+ expect(source).not.toContain('//src/pages');
69
+ });
70
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * contentAssetsIntegration
3
+ *
4
+ * Astro integration that serves static assets (images, PDFs, etc.) co-located
5
+ * in the content directory (usr/content/ by default) without requiring a
6
+ * manual copy step.
7
+ *
8
+ * - Dev server: a Vite middleware intercepts requests for static file extensions
9
+ * and serves them directly from the content directory, mapping URL paths 1:1
10
+ * (e.g. /blog/post/image.jpg → usr/content/blog/post/image.jpg).
11
+ *
12
+ * - Production build: assets are copied from the content directory into dist/
13
+ * with the same relative path structure, so they are available at the
14
+ * expected URLs.
15
+ *
16
+ * This replaces the scripts/copy-content-images.js pre-build script for
17
+ * content-colocated assets.
18
+ */
19
+
20
+ import type { AstroIntegration } from 'astro';
21
+ import { resolve, join, relative, extname, dirname } from 'node:path';
22
+ import { existsSync, statSync, mkdirSync, copyFileSync, readdirSync, createReadStream } from 'node:fs';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ export interface ContentAssetsOptions {
26
+ /** Path to the content directory, relative to the project root. Defaults to 'usr/content'. */
27
+ contentDir?: string;
28
+ }
29
+
30
+ /** File extensions treated as static assets. */
31
+ const STATIC_EXTENSIONS = /\.(jpe?g|png|gif|svg|webp|pdf|avif|mp4|mp3|woff2?|ttf|ico)$/i;
32
+
33
+ const MIME_TYPES: Record<string, string> = {
34
+ jpg: 'image/jpeg',
35
+ jpeg: 'image/jpeg',
36
+ png: 'image/png',
37
+ gif: 'image/gif',
38
+ svg: 'image/svg+xml',
39
+ webp: 'image/webp',
40
+ avif: 'image/avif',
41
+ pdf: 'application/pdf',
42
+ mp4: 'video/mp4',
43
+ mp3: 'audio/mpeg',
44
+ woff: 'font/woff',
45
+ woff2: 'font/woff2',
46
+ ttf: 'font/ttf',
47
+ ico: 'image/x-icon',
48
+ };
49
+
50
+ function collectAssets(dir: string, results: string[] = []): string[] {
51
+ for (const item of readdirSync(dir, { withFileTypes: true })) {
52
+ const fullPath = join(dir, item.name);
53
+ if (item.isDirectory()) {
54
+ collectAssets(fullPath, results);
55
+ } else if (STATIC_EXTENSIONS.test(item.name)) {
56
+ results.push(fullPath);
57
+ }
58
+ }
59
+ return results;
60
+ }
61
+
62
+ export function contentAssetsIntegration(options: ContentAssetsOptions = {}): AstroIntegration {
63
+ const contentDirSegment = options.contentDir ?? join('usr', 'content');
64
+ // Resolved from Astro's own config root in astro:config:setup below, rather
65
+ // than assumed from process.cwd() — more portable if the process is ever
66
+ // invoked from somewhere other than the project root.
67
+ let projectRoot = process.cwd();
68
+
69
+ return {
70
+ name: 'content-assets',
71
+ hooks: {
72
+ 'astro:config:setup': ({ config }) => {
73
+ projectRoot = fileURLToPath(config.root);
74
+ },
75
+
76
+ /**
77
+ * Dev server: add a Connect middleware that serves static assets directly
78
+ * from the content directory without requiring them to be copied to
79
+ * usr/public/.
80
+ */
81
+ 'astro:server:setup': ({ server, logger }) => {
82
+ const contentDir = resolve(projectRoot, contentDirSegment);
83
+
84
+ server.middlewares.use((req, res, next) => {
85
+ const url = req.url?.split('?')[0];
86
+ if (!url || !STATIC_EXTENSIONS.test(url)) return next();
87
+
88
+ // Security: prevent path traversal
89
+ const filePath = resolve(join(contentDir, url));
90
+ if (!filePath.startsWith(contentDir + '/')) return next();
91
+
92
+ if (!existsSync(filePath) || statSync(filePath).isDirectory()) return next();
93
+
94
+ const mime = MIME_TYPES[extname(filePath).slice(1).toLowerCase()] ?? 'application/octet-stream';
95
+ res.setHeader('Content-Type', mime);
96
+ createReadStream(filePath).pipe(res as NodeJS.WritableStream);
97
+ });
98
+
99
+ logger.info(`Serving content assets directly from ${contentDirSegment}/`);
100
+ },
101
+
102
+ /**
103
+ * Production build: copy all static assets from the content directory
104
+ * into the dist/ output directory, preserving the relative path structure.
105
+ */
106
+ 'astro:build:done': ({ dir, logger }) => {
107
+ const contentDir = resolve(projectRoot, contentDirSegment);
108
+ const distDir = fileURLToPath(dir);
109
+
110
+ if (!existsSync(contentDir)) {
111
+ logger.warn(`${contentDirSegment}/ not found – skipping content asset copy`);
112
+ return;
113
+ }
114
+
115
+ const assets = collectAssets(contentDir);
116
+ let count = 0;
117
+
118
+ for (const assetPath of assets) {
119
+ const relPath = relative(contentDir, assetPath);
120
+ const destPath = join(distDir, relPath);
121
+ mkdirSync(dirname(destPath), { recursive: true });
122
+ copyFileSync(assetPath, destPath);
123
+ count++;
124
+ }
125
+
126
+ if (count > 0) {
127
+ logger.info(`Copied ${count} content asset(s) to dist/`);
128
+ }
129
+ },
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Directus Content Loader for Astro Content Collections
3
+ *
4
+ * This loader fetches data from a Directus API and makes it available
5
+ * as an Astro Content Collection.
6
+ */
7
+
8
+ import type { Loader } from 'astro/loaders';
9
+
10
+ export interface DirectusLoaderOptions {
11
+ endpoint?: string;
12
+ token?: string;
13
+ table: string;
14
+ fields?: string[];
15
+ filter?: Record<string, any>;
16
+ sort?: string[];
17
+ limit?: number;
18
+ }
19
+
20
+ export function directusLoader(options: DirectusLoaderOptions): Loader {
21
+ return {
22
+ name: 'directus-loader',
23
+ load: async ({ store, logger }) => {
24
+ const endpoint = options.endpoint || process.env.DIRECTUS_URL;
25
+ const token = options.token || process.env.DIRECTUS_TOKEN;
26
+
27
+ if (!endpoint) {
28
+ throw new Error(
29
+ 'Directus endpoint not configured. Set DIRECTUS_URL in .env or provide endpoint option.'
30
+ );
31
+ }
32
+
33
+ // Build URL
34
+ let url = `${endpoint}/items/${options.table}`;
35
+ const params = new URLSearchParams();
36
+
37
+ if (options.fields) {
38
+ params.append('fields', options.fields.join(','));
39
+ }
40
+
41
+ if (options.filter) {
42
+ Object.entries(options.filter).forEach(([key, value]) => {
43
+ params.append(`filter[${key}]`, String(value));
44
+ });
45
+ }
46
+
47
+ if (options.sort) {
48
+ params.append('sort', options.sort.join(','));
49
+ }
50
+
51
+ if (options.limit) {
52
+ params.append('limit', String(options.limit));
53
+ }
54
+
55
+ const queryString = params.toString();
56
+ if (queryString) {
57
+ url += `?${queryString}`;
58
+ }
59
+
60
+ logger.info(`Fetching from Directus: ${url}`);
61
+
62
+ // Fetch data
63
+ const headers: HeadersInit = {
64
+ 'Content-Type': 'application/json',
65
+ };
66
+
67
+ if (token) {
68
+ headers['Authorization'] = `Bearer ${token}`;
69
+ }
70
+
71
+ try {
72
+ const response = await fetch(url, { headers });
73
+
74
+ if (!response.ok) {
75
+ throw new Error(`Directus API error: ${response.statusText}`);
76
+ }
77
+
78
+ const result = await response.json();
79
+ const items = Array.isArray(result.data) ? result.data : [result.data];
80
+
81
+ logger.info(`Loaded ${items.length} items from Directus table: ${options.table}`);
82
+
83
+ // Store items in the collection
84
+ store.clear();
85
+
86
+ for (const item of items) {
87
+ store.set({
88
+ id: String(item.id),
89
+ data: item,
90
+ });
91
+ }
92
+ } catch (error) {
93
+ logger.error(`Failed to load from Directus: ${error}`);
94
+ throw error;
95
+ }
96
+ },
97
+ };
98
+ }