@lad-sapienza/scms-core 0.1.0-alpha.6 → 0.1.0-alpha.8

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.
package/README.md CHANGED
@@ -51,6 +51,7 @@ import { DataTb, Map, Gallery } from '@lad-sapienza/scms-core';
51
51
  | `MapComponent` (`Map`) | Interactive map with MapLibre GL JS |
52
52
  | `Gallery` | Responsive image gallery with lightbox |
53
53
  | `TableOfContents` | Auto-generated TOC from headings |
54
+ | `RedirectPage` | Static client-side redirect stub (meta-refresh + JS fallback) for static hosts |
54
55
  | `ZoteroGeoViewer` | Zotero library visualised on a map |
55
56
  | `RecordProvider`, `Field`, `Image`, `RecordFetcher`, `useRecordFetcher` | Build single-record detail pages against Directus |
56
57
  | `SearchUI`, `SearchUISimple`, `SearchUIAdvanced` | Field/operator/value search UI (used by `Map`'s `searchInFields`) |
@@ -63,7 +64,7 @@ import BSNavbar from '@lad-sapienza/scms-core/components/BSNavbar';
63
64
  import { directusLoader } from '@lad-sapienza/scms-core/integrations/directusLoader';
64
65
  ```
65
66
 
66
- See each component folder's own `README.md` (`components/DataTb`, `components/Gallery`, `components/ZoteroGeoViewer`, `components/`) for detailed API docs and examples.
67
+ See each component folder's own `README.md` (`components/DataTb`, `components/Gallery`, `components/Map`, `components/RedirectPage`, `components/ZoteroGeoViewer`, `components/`) for detailed API docs and examples.
67
68
 
68
69
  ## Creating a new site
69
70
 
@@ -31,35 +31,11 @@ if (explicitImages && explicitImages.length > 0) {
31
31
  }
32
32
  ---
33
33
 
34
- {finalImages.length > 0 ? (
34
+ {finalImages.length > 0 && (
35
35
  <GalleryClient
36
36
  images={finalImages}
37
37
  columns={columns}
38
38
  className={className}
39
39
  client:idle
40
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
41
  )}
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>
@@ -3,7 +3,7 @@ import { createRoot, type Root } from 'react-dom/client';
3
3
  import { createPortal } from 'react-dom';
4
4
  import { Layers, Search, X } from 'lucide-react';
5
5
  import type { IControl } from 'maplibre-gl';
6
- import type { BaseLayerConfig, VectorLayerConfig, ControlPosition, SearchInFields, SearchQuery } from './types';
6
+ import type { BaseLayerConfig, OverlayLayerConfig, ControlPosition, SearchInFields, SearchQuery } from './types';
7
7
  import { SearchUI } from './Search';
8
8
  import { useTranslation } from '../i18n';
9
9
 
@@ -11,22 +11,22 @@ export interface LayerControlProps {
11
11
  baseLayers?: BaseLayerConfig[];
12
12
  activeBaseLayer?: number;
13
13
  onBaseLayerChange?: (index: number) => void;
14
- vectorLayers?: Array<VectorLayerConfig & { id: string }>;
15
- vectorLayerVisibility?: Record<string, boolean>;
16
- onVectorLayerToggle?: (layerId: string) => void;
14
+ overlayLayers?: Array<OverlayLayerConfig & { id: string }>;
15
+ overlayLayerVisibility?: Record<string, boolean>;
16
+ onOverlayLayerToggle?: (layerId: string) => void;
17
17
  onLayerSearch?: (layerId: string, query: SearchQuery) => void;
18
18
  layerSearchQueries?: Record<string, SearchQuery>;
19
19
  position?: ControlPosition;
20
20
  }
21
21
 
22
22
  // Internal React component for the control UI
23
- function LayerControlUI({
24
- baseLayers = [],
25
- activeBaseLayer = 0,
23
+ function LayerControlUI({
24
+ baseLayers = [],
25
+ activeBaseLayer = 0,
26
26
  onBaseLayerChange,
27
- vectorLayers = [],
28
- vectorLayerVisibility = {},
29
- onVectorLayerToggle,
27
+ overlayLayers = [],
28
+ overlayLayerVisibility = {},
29
+ onOverlayLayerToggle,
30
30
  onLayerSearch,
31
31
  layerSearchQueries = {},
32
32
  }: Omit<LayerControlProps, 'position'>) {
@@ -36,7 +36,7 @@ function LayerControlUI({
36
36
  const radioGroupId = React.useId();
37
37
  const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
38
38
 
39
- const hasContent = baseLayers.length > 0 || vectorLayers.length > 0;
39
+ const hasContent = baseLayers.length > 0 || overlayLayers.length > 0;
40
40
  if (!hasContent) return null;
41
41
 
42
42
  const handleMouseEnter = () => {
@@ -150,20 +150,20 @@ function LayerControlUI({
150
150
  whiteSpace: 'nowrap'
151
151
  }}>
152
152
 
153
- {/* Vector Layers Section */}
154
- {vectorLayers.length > 0 && (
153
+ {/* Overlay Layers Section */}
154
+ {overlayLayers.length > 0 && (
155
155
  <div style={{ marginBottom: 10 }}>
156
- {vectorLayers.map((layer) => (
156
+ {overlayLayers.map((layer) => (
157
157
  <div key={layer.id} style={{ marginBottom: 6, display: 'flex', alignItems: 'center' }}>
158
158
  <input
159
159
  type="checkbox"
160
- id={`vector-layer-${layer.id}`}
161
- checked={vectorLayerVisibility[layer.id] !== false}
162
- onChange={() => onVectorLayerToggle?.(layer.id)}
160
+ id={`overlay-layer-${layer.id}`}
161
+ checked={overlayLayerVisibility[layer.id] !== false}
162
+ onChange={() => onOverlayLayerToggle?.(layer.id)}
163
163
  style={{ marginRight: 8, cursor: 'pointer' }}
164
164
  />
165
- <label
166
- htmlFor={`vector-layer-${layer.id}`}
165
+ <label
166
+ htmlFor={`overlay-layer-${layer.id}`}
167
167
  style={{ cursor: 'pointer', fontSize: '12px', userSelect: 'none', flex: 1 }}
168
168
  >
169
169
  {layer.name}
@@ -321,7 +321,7 @@ export class LayerControlClass implements IControl {
321
321
  // Compare props to avoid unnecessary re-renders
322
322
  const hasChanged =
323
323
  JSON.stringify(this.props.layerSearchQueries) !== JSON.stringify(newProps.layerSearchQueries) ||
324
- JSON.stringify(this.props.vectorLayerVisibility) !== JSON.stringify(newProps.vectorLayerVisibility) ||
324
+ JSON.stringify(this.props.overlayLayerVisibility) !== JSON.stringify(newProps.overlayLayerVisibility) ||
325
325
  this.props.activeBaseLayer !== newProps.activeBaseLayer;
326
326
 
327
327
  if (hasChanged) {
@@ -354,7 +354,7 @@ export function LayerControl(props: LayerControlProps) {
354
354
  }
355
355
  }
356
356
  previousPropsRef.current = props;
357
- }, [props.layerSearchQueries, props.vectorLayerVisibility, props.activeBaseLayer]);
357
+ }, [props.layerSearchQueries, props.overlayLayerVisibility, props.activeBaseLayer]);
358
358
 
359
359
  // Create control instance only once
360
360
  if (!controlRef.current) {
@@ -10,14 +10,15 @@ import MapLibreMap, {
10
10
  type MapRef
11
11
  } from '@vis.gl/react-maplibre';
12
12
  import 'maplibre-gl/dist/maplibre-gl.css';
13
- import type { MapProps, VectorLayerConfig } from './types';
13
+ import type { MapProps, OverlayLayerConfig } from './types';
14
14
  import { RasterLayerLibre } from './RasterLayerLibre';
15
15
  import { LayerControlIControl } from './LayerControl';
16
16
  import { directusShorthandToConfig } from '../../utils/directus-config';
17
17
  import type { BaseLayerConfig } from './types';
18
18
  import { defaultBasemaps, getBasemap } from './defaultBasemaps';
19
19
  import { fetchData } from '../../utils/data-fetcher';
20
- import { dataToGeoJson, parseStringTemplate, filterObjectToPredicate, searchQueryToMapLibreFilter, searchQueryToPredicate } from './utils';
20
+ import type { SourceConfig } from '../../utils/data-fetcher';
21
+ import { dataToGeoJson, parseStringTemplate, filterObjectToPredicate, searchQueryToMapLibreFilter, searchQueryToPredicate, expandXyzSubdomains, buildWmsUrl } from './utils';
21
22
  import { useTranslation } from '../i18n';
22
23
  import type { FeatureCollection } from 'geojson';
23
24
  import type { CircleLayerSpecification } from 'maplibre-gl';
@@ -26,13 +27,20 @@ import type { SearchQuery } from './types';
26
27
  // Default base layer (OSM)
27
28
  const DEFAULT_BASE_LAYERS: BaseLayerConfig[] = [defaultBasemaps.OSM];
28
29
 
30
+ // Vector-tile, xyz and wms sources are rendered natively/via RasterLayerLibre
31
+ // and never go through fetchData — this narrows the union so TS knows
32
+ // `layer.source` is a plain SourceConfig wherever this guard is used.
33
+ function isFetchableLayer(layer: OverlayLayerConfig): layer is OverlayLayerConfig & { source: SourceConfig } {
34
+ return layer.source.type !== 'vector' && layer.source.type !== 'xyz' && layer.source.type !== 'wms';
35
+ }
36
+
29
37
  export function Map({
30
38
  height = '600px',
31
39
  center = '0,0,2',
32
40
  mapStyle,
33
41
  styleOverrides,
34
42
  baseLayers = DEFAULT_BASE_LAYERS,
35
- vectorLayers = [],
43
+ overlayLayers = [],
36
44
  geolocateControl,
37
45
  fullscreenControl,
38
46
  navigationControl = 'top-left',
@@ -48,15 +56,15 @@ export function Map({
48
56
  const [activeBaseLayer, setActiveBaseLayer] = useState<number>(0);
49
57
  const [layersData, setLayersData] = useState<Record<string, FeatureCollection>>({});
50
58
  const [hoveredFeature, setHoveredFeature] = useState<any>(null);
51
- const [vectorLayerVisibility, setVectorLayerVisibility] = useState<Record<string, boolean>>({});
59
+ const [overlayLayerVisibility, setOverlayLayerVisibility] = useState<Record<string, boolean>>({});
52
60
  const [mapLoaded, setMapLoaded] = useState(false);
53
61
  const mapRef = useRef<MapRef>(null);
54
- const loadedLayers = useRef<Set<string>>(new Set());
62
+ const rawSourceCache = useRef<globalThis.Map<string, FeatureCollection>>(new globalThis.Map());
55
63
  const layerControlRef = useRef<LayerControlIControl | null>(null);
56
64
  const [parsedStyleLayers, setParsedStyleLayers] = useState<{
57
65
  baseLayers: BaseLayerConfig[];
58
- vectorLayers: VectorLayerConfig[];
59
- }>({ baseLayers: [], vectorLayers: [] });
66
+ overlayLayers: OverlayLayerConfig[];
67
+ }>({ baseLayers: [], overlayLayers: [] });
60
68
  const [mergedMapStyle, setMergedMapStyle] = useState<any>(null);
61
69
  const [layerExpansions, setLayerExpansions] = useState<Record<string, {
62
70
  popupTemplate?: string;
@@ -67,7 +75,7 @@ export function Map({
67
75
  // Parse mapStyle JSON to extract layers and apply overrides
68
76
  useEffect(() => {
69
77
  if (!mapStyle) {
70
- setParsedStyleLayers({ baseLayers: [], vectorLayers: [] });
78
+ setParsedStyleLayers({ baseLayers: [], overlayLayers: [] });
71
79
  setMergedMapStyle(null);
72
80
  setLayerExpansions({});
73
81
  return;
@@ -86,7 +94,7 @@ export function Map({
86
94
  }
87
95
 
88
96
  const baseLayers: BaseLayerConfig[] = [];
89
- const vectorLayers: VectorLayerConfig[] = [];
97
+ const overlayLayers: OverlayLayerConfig[] = [];
90
98
  const expansions: Record<string, any> = {};
91
99
 
92
100
  // Apply styleOverrides to layers
@@ -148,7 +156,7 @@ export function Map({
148
156
  const source = styleObj.sources[layer.source];
149
157
  if (source && source.type === 'vector') {
150
158
  const expansion = expansions[layer.id] || {};
151
- vectorLayers.push({
159
+ overlayLayers.push({
152
160
  name: layer.metadata?.label || layer.id,
153
161
  source: {
154
162
  type: 'vector',
@@ -164,11 +172,11 @@ export function Map({
164
172
  });
165
173
  }
166
174
 
167
- setParsedStyleLayers({ baseLayers, vectorLayers });
175
+ setParsedStyleLayers({ baseLayers, overlayLayers });
168
176
  setMergedMapStyle(styleObj);
169
177
  } catch (error) {
170
178
  console.error('Failed to parse mapStyle:', error);
171
- setParsedStyleLayers({ baseLayers: [], vectorLayers: [] });
179
+ setParsedStyleLayers({ baseLayers: [], overlayLayers: [] });
172
180
  setMergedMapStyle(null);
173
181
  setLayerExpansions({});
174
182
  }
@@ -234,13 +242,13 @@ export function Map({
234
242
  return null;
235
243
  }, [geojson, csv, json, directus]);
236
244
 
237
- // Combine explicit vectorLayers with implicit source
238
- const allVectorLayers = useMemo(() => {
239
- let layers = [...vectorLayers];
240
-
245
+ // Combine explicit overlayLayers with implicit source
246
+ const allOverlayLayers = useMemo(() => {
247
+ let layers = [...overlayLayers];
248
+
241
249
  // If using mapStyle, add parsed vector layers from style JSON
242
- if (mapStyle && parsedStyleLayers.vectorLayers.length > 0) {
243
- layers = [...parsedStyleLayers.vectorLayers, ...layers];
250
+ if (mapStyle && parsedStyleLayers.overlayLayers.length > 0) {
251
+ layers = [...parsedStyleLayers.overlayLayers, ...layers];
244
252
  }
245
253
 
246
254
  if (implicitSource) {
@@ -288,28 +296,28 @@ export function Map({
288
296
  layers.push(layerConfig);
289
297
  }
290
298
  return layers;
291
- }, [vectorLayers, implicitSource, geojson, csv, mapStyle, parsedStyleLayers.vectorLayers]);
299
+ }, [overlayLayers, implicitSource, geojson, csv, mapStyle, parsedStyleLayers.overlayLayers]);
292
300
 
293
301
  // Initialize and update visibility when layers change
294
302
  useEffect(() => {
295
- setVectorLayerVisibility(prev => {
303
+ setOverlayLayerVisibility(prev => {
296
304
  const newVisibility: Record<string, boolean> = { ...prev };
297
305
  let hasChanges = false;
298
-
299
- allVectorLayers.forEach(layer => {
306
+
307
+ allOverlayLayers.forEach(layer => {
300
308
  const layerId = `layer-${layer.name.replace(/\s+/g, '-')}`;
301
-
309
+
302
310
  if (newVisibility[layerId] === undefined) {
303
311
  newVisibility[layerId] = layer.visible !== false;
304
312
  hasChanges = true;
305
313
  }
306
314
  });
307
-
315
+
308
316
  return hasChanges ? newVisibility : prev;
309
317
  });
310
- }, [allVectorLayers]);
318
+ }, [allOverlayLayers]);
311
319
 
312
- // Handle vector layer visibility toggle
320
+ // Handle overlay layer visibility toggle
313
321
  //
314
322
  // Wrapped in useCallback (both here and below) so its identity stays
315
323
  // stable across renders — it's one of two functions passed into the
@@ -324,14 +332,14 @@ export function Map({
324
332
  // created (correct) control was the one actually getting removed a tick
325
333
  // later, leaving the stale first control (still showing the original
326
334
  // basemap selected) as the only one left on the map.
327
- const handleVectorLayerToggle = useCallback((layerId: string) => {
328
- setVectorLayerVisibility(prev => ({
335
+ const handleOverlayLayerToggle = useCallback((layerId: string) => {
336
+ setOverlayLayerVisibility(prev => ({
329
337
  ...prev,
330
338
  [layerId]: !prev[layerId]
331
339
  }));
332
340
  }, []);
333
341
 
334
- // Handle search for vector layers — see handleVectorLayerToggle above.
342
+ // Handle search for overlay layers — see handleOverlayLayerToggle above.
335
343
  const handleLayerSearch = useCallback((layerId: string, query: SearchQuery) => {
336
344
  setLayerSearchQueries(prev => ({
337
345
  ...prev,
@@ -339,71 +347,68 @@ export function Map({
339
347
  }));
340
348
  }, []);
341
349
 
342
- // Load data for all vector layers
350
+ // Load data for all fetchable overlay layers (vector-tile, xyz and wms
351
+ // sources are rendered natively/via RasterLayerLibre and never reach
352
+ // fetchData). Raw fetched+converted GeoJSON is cached per unique source
353
+ // (rawSourceCache, keyed by JSON.stringify(source)) so N layer entries
354
+ // sharing the same source — differing only in filter/style — fetch once;
355
+ // each layer's own filter is applied on top of the cached raw data.
356
+ // Loading no longer depends on `visible`: visibility only controls
357
+ // display (see the render below), not whether data is ever fetched —
358
+ // otherwise a layer started as visible:false could never be loaded even
359
+ // after the user later checks it on, since this effect only re-runs when
360
+ // allOverlayLayers itself changes, not on a visibility toggle.
343
361
  useEffect(() => {
344
362
  const loadAllLayers = async () => {
345
- const newLayersData: Record<string, FeatureCollection> = {};
346
- const layersToLoad: typeof allVectorLayers = [];
347
-
348
- // Check which layers need to be loaded
349
- for (const layer of allVectorLayers) {
350
- // Vector-tile sources (parsed from a mapStyle JSON) are rendered
351
- // natively by MapLibre from the style's own tiles/sources — they
352
- // aren't rows to fetch, so skip them here to avoid a spurious
353
- // "unsupported source type" error on every load.
354
- if (layer.source.type === 'vector') continue;
363
+ const fetchableLayers = allOverlayLayers.filter(isFetchableLayer);
364
+ if (fetchableLayers.length === 0) return;
355
365
 
366
+ const seen = new Set<string>();
367
+ for (const layer of fetchableLayers) {
356
368
  const layerKey = JSON.stringify(layer.source);
357
- if (!loadedLayers.current.has(layerKey) && layer.visible !== false) {
358
- layersToLoad.push(layer);
359
- }
360
- }
361
-
362
- // Only proceed if there are new layers to load
363
- if (layersToLoad.length === 0) return;
364
-
365
- for (const layer of layersToLoad) {
369
+ if (rawSourceCache.current.has(layerKey) || seen.has(layerKey)) continue;
370
+ seen.add(layerKey);
371
+
366
372
  try {
367
373
  const data = await fetchData(layer.source);
368
-
374
+
369
375
  // Extract custom column names from CSV source if provided
370
376
  const customLng = layer.source.type === 'csv' ? layer.source.lng : undefined;
371
377
  const customLat = layer.source.type === 'csv' ? layer.source.lat : undefined;
372
-
378
+
373
379
  // Extract geoField from Directus source if provided
374
380
  const geoField = layer.source.type === 'directus' ? layer.source.geoField : undefined;
375
-
376
- let geoJson = dataToGeoJson(data, customLng, customLat, geoField);
377
-
378
- // Apply client-side filter if provided
379
- if (layer.filter && typeof layer.filter === 'function') {
380
- geoJson = {
381
- ...geoJson,
382
- features: geoJson.features.filter(layer.filter)
383
- };
384
- }
385
-
386
- newLayersData[layer.name] = geoJson;
387
-
388
- // Mark this layer as loaded
389
- const layerKey = JSON.stringify(layer.source);
390
- loadedLayers.current.add(layerKey);
381
+
382
+ const geoJson = dataToGeoJson(data, customLng, customLat, geoField);
383
+ rawSourceCache.current.set(layerKey, geoJson);
391
384
  } catch (err) {
392
385
  console.error(`Failed to load layer "${layer.name}":`, err);
393
386
  // Continue loading other layers even if one fails
394
387
  }
395
388
  }
396
-
397
- // Only update state if we actually loaded new data
389
+
390
+ // Project cached raw data into each layer's own (filtered) view —
391
+ // cheap client-side work, no network, safe to redo whenever
392
+ // allOverlayLayers changes (e.g. a filter edit).
393
+ const newLayersData: Record<string, FeatureCollection> = {};
394
+ for (const layer of fetchableLayers) {
395
+ const raw = rawSourceCache.current.get(JSON.stringify(layer.source));
396
+ if (!raw) continue; // fetch failed for this source
397
+
398
+ newLayersData[layer.name] = layer.filter && typeof layer.filter === 'function'
399
+ ? { ...raw, features: raw.features.filter(layer.filter) }
400
+ : raw;
401
+ }
402
+
398
403
  if (Object.keys(newLayersData).length > 0) {
399
404
  setLayersData(prev => ({ ...prev, ...newLayersData }));
400
405
  }
401
406
  };
402
-
403
- if (allVectorLayers.length > 0 && typeof window !== 'undefined') {
407
+
408
+ if (allOverlayLayers.length > 0 && typeof window !== 'undefined') {
404
409
  loadAllLayers();
405
410
  }
406
- }, [allVectorLayers]);
411
+ }, [allOverlayLayers]);
407
412
 
408
413
  // Handle fitToContent separately when map is loaded and data is ready
409
414
  useEffect(() => {
@@ -411,7 +416,7 @@ export function Map({
411
416
  if (!map || !mapLoaded || Object.keys(layersData).length === 0) return;
412
417
 
413
418
  // Find first layer with fitToContent
414
- const layerToFit = allVectorLayers.find(layer =>
419
+ const layerToFit = allOverlayLayers.find(layer =>
415
420
  layer.fitToContent && layersData[layer.name] && layersData[layer.name].features.length > 0
416
421
  );
417
422
 
@@ -449,30 +454,30 @@ export function Map({
449
454
  map.fitBounds(bounds, { padding: 50, duration: 1000 });
450
455
  }
451
456
  }, 100);
452
- }, [mapLoaded, layersData, allVectorLayers]);
457
+ }, [mapLoaded, layersData, allOverlayLayers]);
453
458
 
454
459
  // Add LayerControl as a proper IControl
455
460
  useEffect(() => {
456
461
  const map = mapRef.current?.getMap();
457
462
  if (!map || !mapLoaded || !layerControl) return;
458
-
459
- // Show layer control if we have base layers or vector layers
460
- const hasLayers = resolvedBaseLayers.length > 0 || allVectorLayers.length > 0;
463
+
464
+ // Show layer control if we have base layers or overlay layers
465
+ const hasLayers = resolvedBaseLayers.length > 0 || allOverlayLayers.length > 0;
461
466
  if (!hasLayers) return;
462
467
 
463
468
  const position = typeof layerControl === 'string' ? layerControl : 'top-right';
464
-
469
+
465
470
  // Create and add control
466
471
  const control = new LayerControlIControl({
467
472
  baseLayers: resolvedBaseLayers,
468
473
  activeBaseLayer,
469
474
  onBaseLayerChange: setActiveBaseLayer,
470
- vectorLayers: allVectorLayers.map(layer => ({
475
+ overlayLayers: allOverlayLayers.map(layer => ({
471
476
  ...layer,
472
477
  id: `layer-${layer.name.replace(/\s+/g, '-')}`
473
478
  })),
474
- vectorLayerVisibility,
475
- onVectorLayerToggle: handleVectorLayerToggle,
479
+ overlayLayerVisibility,
480
+ onOverlayLayerToggle: handleOverlayLayerToggle,
476
481
  onLayerSearch: handleLayerSearch,
477
482
  layerSearchQueries
478
483
  });
@@ -490,7 +495,7 @@ export function Map({
490
495
  map.removeControl(control);
491
496
  }, 0);
492
497
  };
493
- }, [mapLoaded, layerControl, resolvedBaseLayers, allVectorLayers.length, handleLayerSearch]);
498
+ }, [mapLoaded, layerControl, resolvedBaseLayers, allOverlayLayers.length, handleLayerSearch]);
494
499
 
495
500
  // Update layer control props when they change
496
501
  useEffect(() => {
@@ -499,17 +504,17 @@ export function Map({
499
504
  baseLayers: resolvedBaseLayers,
500
505
  activeBaseLayer,
501
506
  onBaseLayerChange: setActiveBaseLayer,
502
- vectorLayers: allVectorLayers.map(layer => ({
507
+ overlayLayers: allOverlayLayers.map(layer => ({
503
508
  ...layer,
504
509
  id: `layer-${layer.name.replace(/\s+/g, '-')}`
505
510
  })),
506
- vectorLayerVisibility,
507
- onVectorLayerToggle: handleVectorLayerToggle,
511
+ overlayLayerVisibility,
512
+ onOverlayLayerToggle: handleOverlayLayerToggle,
508
513
  onLayerSearch: handleLayerSearch,
509
514
  layerSearchQueries
510
515
  });
511
516
  }
512
- }, [resolvedBaseLayers, activeBaseLayer, allVectorLayers, vectorLayerVisibility, handleLayerSearch, layerSearchQueries]);
517
+ }, [resolvedBaseLayers, activeBaseLayer, allOverlayLayers, overlayLayerVisibility, handleLayerSearch, layerSearchQueries]);
513
518
 
514
519
  // Control visibility of layers from style JSON
515
520
  useEffect(() => {
@@ -517,10 +522,10 @@ export function Map({
517
522
  if (!map || !mapLoaded || !mapStyle) return;
518
523
 
519
524
  // Update visibility for vector layers from parsed style
520
- parsedStyleLayers.vectorLayers.forEach(layer => {
525
+ parsedStyleLayers.overlayLayers.forEach(layer => {
521
526
  const layerId = `layer-${layer.name.replace(/\s+/g, '-')}`;
522
- const isVisible = vectorLayerVisibility[layerId] !== false;
523
-
527
+ const isVisible = overlayLayerVisibility[layerId] !== false;
528
+
524
529
  // The original layer ID from the style JSON
525
530
  const styleLayerId = layer.style?.id;
526
531
  if (styleLayerId && map.getLayer(styleLayerId)) {
@@ -531,7 +536,7 @@ export function Map({
531
536
  );
532
537
  }
533
538
  });
534
- }, [vectorLayerVisibility, mapLoaded, mapStyle, parsedStyleLayers.vectorLayers]);
539
+ }, [overlayLayerVisibility, mapLoaded, mapStyle, parsedStyleLayers.overlayLayers]);
535
540
 
536
541
  // Handle fitToContent for layers with expansions
537
542
  useEffect(() => {
@@ -604,8 +609,8 @@ export function Map({
604
609
  if (e.features && e.features.length > 0) {
605
610
  const clickedLayerId = e.features[0].layer?.id;
606
611
 
607
- // Check for popup in allVectorLayers (includes parsed style layers)
608
- const layerConfig = allVectorLayers.find(l => {
612
+ // Check for popup in allOverlayLayers (includes parsed style layers)
613
+ const layerConfig = allOverlayLayers.find(l => {
609
614
  const layerId = `layer-${l.name.replace(/\s+/g, '-')}`;
610
615
  return layerId === clickedLayerId;
611
616
  });
@@ -624,30 +629,50 @@ export function Map({
624
629
  }
625
630
  }}
626
631
  interactiveLayerIds={[
627
- ...allVectorLayers.map(l => `layer-${l.name.replace(/\s+/g, '-')}`),
632
+ ...allOverlayLayers.map(l => `layer-${l.name.replace(/\s+/g, '-')}`),
628
633
  ...Object.keys(layerExpansions) // Add layer IDs that have expansions
629
634
  ]}
630
635
  >
631
636
  {/* Base Layers (only if no full mapStyle is provided) - render ALL but control visibility */}
632
637
  {!mapStyle && resolvedBaseLayers.map((layer, index) => (
633
- <RasterLayerLibre
638
+ <RasterLayerLibre
634
639
  key={`basemap-${index}`}
635
640
  id={`base-layer-${index}`}
636
- url={[layer.url]}
641
+ url={expandXyzSubdomains(layer.url)}
637
642
  attribution={layer.attribution}
643
+ tileSize={layer.tileSize}
638
644
  visible={index === activeBaseLayer}
639
645
  />
640
646
  ))}
641
647
 
642
- {/* Vector Layers from prop - render AFTER base layer so they appear on top */}
643
- {allVectorLayers.map((layer) => {
644
- const geoJson = layersData[layer.name];
648
+ {/* Overlay Layers from prop - render AFTER base layer so they appear on top */}
649
+ {allOverlayLayers.map((layer) => {
645
650
  const layerId = `layer-${layer.name.replace(/\s+/g, '-')}`;
651
+ const isVisible = overlayLayerVisibility[layerId] !== false;
652
+
653
+ // Raster overlays (XYZ/WMS) render natively via MapLibre tiles —
654
+ // they never go through fetchData/dataToGeoJson/layersData.
655
+ if (layer.source.type === 'xyz' || layer.source.type === 'wms') {
656
+ const urls = layer.source.type === 'xyz'
657
+ ? expandXyzSubdomains(layer.source.url)
658
+ : [buildWmsUrl(layer.source)];
659
+ return (
660
+ <RasterLayerLibre
661
+ key={layerId}
662
+ id={layerId}
663
+ url={urls}
664
+ tileSize={layer.source.tileSize}
665
+ attribution={layer.source.attribution}
666
+ visible={isVisible}
667
+ />
668
+ );
669
+ }
670
+
671
+ const geoJson = layersData[layer.name];
646
672
  const sourceId = `source-${layer.name}`;
647
- const isVisible = vectorLayerVisibility[layerId] !== false;
648
-
673
+
649
674
  if (!geoJson) return null;
650
-
675
+
651
676
  // Apply search filter if present
652
677
  const searchQuery = layerSearchQueries[layerId];
653
678
  let filteredGeoJson = geoJson;
@@ -18,7 +18,7 @@ export function Map(props: MapProps) {
18
18
 
19
19
  // Memoize array/object props to prevent unnecessary re-renders in MDX
20
20
  const stableBaseLayers = useMemo(() => props?.baseLayers, [JSON.stringify(props?.baseLayers || [])]);
21
- const stableVectorLayers = useMemo(() => props?.vectorLayers, [JSON.stringify(props?.vectorLayers || [])]);
21
+ const stableOverlayLayers = useMemo(() => props?.overlayLayers, [JSON.stringify(props?.overlayLayers || [])]);
22
22
  const stableGeojson = useMemo(() => props?.geojson, [JSON.stringify(props?.geojson || null)]);
23
23
  const stableCsv = useMemo(() => props?.csv, [JSON.stringify(props?.csv || null)]);
24
24
  const stableJson = useMemo(() => props?.json, [JSON.stringify(props?.json || null)]);
@@ -28,7 +28,7 @@ export function Map(props: MapProps) {
28
28
  {...props}
29
29
  center={stableCenter}
30
30
  baseLayers={stableBaseLayers}
31
- vectorLayers={stableVectorLayers}
31
+ overlayLayers={stableOverlayLayers}
32
32
  geojson={stableGeojson}
33
33
  csv={stableCsv}
34
34
  json={stableJson}
@@ -0,0 +1,201 @@
1
+ # Map Component
2
+
3
+ Interactive map built on [MapLibre GL JS](https://maplibre.org/) (via
4
+ `@vis.gl/react-maplibre`). Renders a raster basemap, any number of toggleable
5
+ overlay layers pulled from heterogeneous data sources (GeoJSON, CSV, JSON,
6
+ Directus, a generic API, or raster XYZ/WMS tiles), and a built-in layer
7
+ control for switching basemaps and toggling overlays.
8
+
9
+ ## Basic usage
10
+
11
+ ```mdx
12
+ import { Map } from '@lad-sapienza/scms-core';
13
+
14
+ <Map />
15
+ ```
16
+
17
+ With no props, `Map` renders a single OSM basemap centered on `0,0` at zoom
18
+ `2`. The shorthand props below cover the common single-layer case without
19
+ touching `overlayLayers` directly:
20
+
21
+ ```mdx
22
+ <Map geojson="/data/sites.geojson" popup="<b>${name}</b>" fitToContent />
23
+
24
+ <Map csv={{ path: '/data/sites.csv', lng: 'longitude', lat: 'latitude' }} />
25
+
26
+ <Map directus={{ table: 'sites', geoField: 'location' }} />
27
+ ```
28
+
29
+ `geojson`/`csv`/`json`/`directus` each accept either a bare string (path/URL)
30
+ or an object with `name`, `style`, `popup`, `fitToContent`, `filter` — see
31
+ `MapProps` in [`types.ts`](./types.ts) for the exact shape of each.
32
+
33
+ ## Base layers (`baseLayers`)
34
+
35
+ Base layers are **radio-exclusive** — exactly one is visible at a time,
36
+ switched via the layer control's "BASE MAPS" section. Accepts either keys
37
+ from the built-in registry ([`defaultBasemaps.ts`](./defaultBasemaps.ts): OSM,
38
+ EsriSatellite, EsriStreets, EsriTopo, GoogleSatellite/Roadmap/Terrain,
39
+ CartoDB, Imperium, CAWM, ...) or custom `BaseLayerConfig` objects, and the two
40
+ forms can be mixed in the same array:
41
+
42
+ ```mdx
43
+ <Map baseLayers={['OSM', 'EsriSatellite']} />
44
+
45
+ <Map baseLayers={[
46
+ { name: 'My WMTS', url: 'https://tiles.example.org/{z}/{x}/{y}.png', tileSize: 512 },
47
+ 'OSM',
48
+ ]} />
49
+ ```
50
+
51
+ `{s}` in a tile URL (subdomain rotation, e.g. `https://{s}.tile.osm.org/...`)
52
+ is expanded automatically into `a`/`b`/`c`. `tileSize` defaults to 256 and
53
+ only needs setting for services that don't use standard 256px tiles.
54
+
55
+ ## Overlay layers (`overlayLayers`)
56
+
57
+ Overlays are **independent and toggleable** (checkbox in the layer control) —
58
+ any number can be visible at once, layered on top of the basemap in array
59
+ order. Each entry is a `name` plus a `source`, where `source.type` picks one
60
+ of two families:
61
+
62
+ **Row/feature sources** — fetched once, converted to GeoJSON, and rendered as
63
+ a MapLibre `circle`/custom-style layer. Supports `style` (paint/layout
64
+ overrides), `popupTemplate`, `fitToContent`, `filter`, `searchInFields`:
65
+
66
+ | `source.type` | Notes |
67
+ |---|---|
68
+ | `geojson` | `{ data }` inline or `{ url }` to fetch |
69
+ | `csv` | `{ url, lng?, lat?, delimiter?, skipRows? }` — auto-detects lat/lng column names if `lng`/`lat` aren't given |
70
+ | `json` | `{ data }` inline array or `{ url }` |
71
+ | `directus` | Directus collection query, `geoField` names the geometry column |
72
+ | `api` | Generic `{ url, method?, headers?, body?, transformer? }` |
73
+ | `vector` | MapLibre vector-tile source (only produced internally when parsing a full `mapStyle`) |
74
+
75
+ If two overlay entries point at the exact same `source` (deep-equal), the
76
+ underlying fetch happens only once — each entry's own `filter` is applied on
77
+ top of the shared cached data, so e.g. the same Directus table can back
78
+ several differently-filtered/styled layers without redundant requests.
79
+
80
+ **Raster tile sources** — rendered directly as MapLibre raster tiles (same
81
+ code path as `baseLayers`), never fetched/converted. `style`, `popupTemplate`,
82
+ `fitToContent`, `filter` and `searchInFields` don't apply to these:
83
+
84
+ ```mdx
85
+ <Map
86
+ baseLayers={['OSM']}
87
+ overlayLayers={[{
88
+ name: 'Satellite',
89
+ source: { type: 'xyz', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}' },
90
+ }]}
91
+ />
92
+ ```
93
+
94
+ This is the case that motivated adding raster support to `overlayLayers` in
95
+ the first place: a raster layer (historical map, satellite imagery, ...)
96
+ shown as an independent overlay on top of a basemap the user picks — not
97
+ possible before, since a raster source could previously only be a
98
+ radio-exclusive `baseLayers` entry.
99
+
100
+ `XyzSourceConfig`: `{ type: 'xyz', url, tileSize?, attribution? }` — same
101
+ `{s}` subdomain expansion and `tileSize` default as `baseLayers`.
102
+
103
+ `WmsSourceConfig`: `{ type: 'wms', url, layers, format?, version?, transparent?, styles?, crs?, tileSize?, attribution? }` builds a GetMap tile URL
104
+ using MapLibre's `{bbox-epsg-3857}` placeholder. Defaults: `format:
105
+ 'image/png'`, `version: '1.3.0'`, `transparent: true`, `crs: 'EPSG:3857'`,
106
+ `tileSize: 256`.
107
+
108
+ ```mdx
109
+ <Map overlayLayers={[{
110
+ name: 'Historical layer',
111
+ source: {
112
+ type: 'wms',
113
+ url: 'https://wms.example.org/geoserver/wms',
114
+ layers: 'workspace:layer_name',
115
+ },
116
+ }]} />
117
+ ```
118
+
119
+ Both raster source types need the tile server to send CORS headers
120
+ (`Access-Control-Allow-Origin`) — MapLibre renders tiles as WebGL textures,
121
+ which browsers refuse to upload cross-origin without a successful CORS
122
+ check, unlike a plain `<img>`-based renderer (e.g. Leaflet). If tiles fail to
123
+ load with a CORS error in the console, the fix is on the tile server, not
124
+ here — for object-storage-backed static tile sets (e.g. OpenStack Swift),
125
+ this is typically one container-metadata header
126
+ (`X-Container-Meta-Access-Control-Allow-Origin: *`).
127
+
128
+ ## Controls
129
+
130
+ | Prop | Type | Default | Description |
131
+ |---|---|---|---|
132
+ | `layerControl` | `boolean \| ControlPosition` | `'top-right'` | Basemap radio + overlay checkboxes. `false` hides it. |
133
+ | `navigationControl` | `ControlPosition` | `'top-left'` | Zoom +/- and compass |
134
+ | `geolocateControl` | `ControlPosition` | — | "find my location" |
135
+ | `fullscreenControl` | `ControlPosition` | — | Fullscreen toggle |
136
+ | `scaleControl` | `ControlPosition` | — | Scale bar |
137
+
138
+ `ControlPosition` is one of `'top-right' | 'top-left' | 'bottom-right' |
139
+ 'bottom-left'`.
140
+
141
+ ## Full style mode (`mapStyle`)
142
+
143
+ Pass a full MapLibre style URL/object via `mapStyle` to bypass `baseLayers`/
144
+ `overlayLayers` resolution entirely — raster sources in the style become
145
+ basemap entries, vector-tile layers become overlay entries, and
146
+ `styleOverrides` can patch individual style layers' `paint`/`layout` plus
147
+ add `popupTemplate`/`fitToContent`/`visible` (properties the style spec
148
+ itself has no room for). See `MapProps` in [`types.ts`](./types.ts) for the
149
+ exact shape.
150
+
151
+ ## Props
152
+
153
+ ### `Map`
154
+
155
+ | Prop | Type | Default | Description |
156
+ |---|---|---|---|
157
+ | `height` | `string` | `'600px'` | CSS height |
158
+ | `center` | `string` | `'0,0,2'` | `"lng,lat,zoom"` |
159
+ | `baseLayers` | `(BaseLayerConfig \| BasemapKey)[]` | `['OSM']` | See above |
160
+ | `overlayLayers` | `OverlayLayerConfig[]` | `[]` | See above |
161
+ | `mapStyle` | `string \| object` | — | Full MapLibre style, see above |
162
+ | `styleOverrides` | `object` | — | Per-layer patches when using `mapStyle` |
163
+ | `geojson` / `csv` / `json` / `directus` | — | — | Single-layer shorthands, see Basic usage |
164
+ | `sprite` | `string` | — | Sprite URL |
165
+
166
+ ### `BaseLayerConfig`
167
+
168
+ | Field | Type | Description |
169
+ |---|---|---|
170
+ | `name` | `string` | Label in the layer control |
171
+ | `url` | `string` | Tile URL template, `{z}/{x}/{y}` (+ optional `{s}`) |
172
+ | `attribution` | `string?` | Attribution HTML |
173
+ | `tileSize` | `number?` | Default 256 |
174
+
175
+ ### `OverlayLayerConfig`
176
+
177
+ | Field | Type | Description |
178
+ |---|---|---|
179
+ | `name` | `string` | Label in the layer control |
180
+ | `source` | `SourceConfig \| XyzSourceConfig \| WmsSourceConfig` | See above |
181
+ | `style` | `any?` | MapLibre paint/layout override (row/feature sources only) |
182
+ | `popupTemplate` | `string?` | e.g. `"<b>${Title}</b>: ${Description}"` (row/feature sources only) |
183
+ | `visible` | `boolean?` | Initial visibility (default `true`) |
184
+ | `fitToContent` | `boolean?` | Fit map bounds to this layer's data on load (row/feature sources only) |
185
+ | `filter` | `FilterObject \| ((feature) => boolean)?` | Client-side filter (row/feature sources only) |
186
+ | `searchInFields` | `SearchInFields?` | Enables the per-layer search UI (row/feature sources only) |
187
+
188
+ ## Architecture notes
189
+
190
+ - `Map.tsx` renders `RasterLayerLibre` for every `baseLayers` entry and for
191
+ any `overlayLayers` entry whose `source.type` is `xyz`/`wms`; everything
192
+ else goes through `fetchData`/`dataToGeoJson`
193
+ ([`utils/data-fetcher.ts`](../../utils/data-fetcher.ts),
194
+ [`utils.ts`](./utils.ts)) into a MapLibre `geojson` source.
195
+ - `LayerControl.tsx` is a plain MapLibre `IControl` (not a React child of the
196
+ map) so it survives the basemap/overlay state living in `Map.tsx`'s own
197
+ React state rather than needing a portal.
198
+ - `expandXyzSubdomains`/`buildWmsUrl` (in [`utils.ts`](./utils.ts)) are the
199
+ only two functions that build tile URL templates — both `baseLayers` and
200
+ raster `overlayLayers` go through them, so there's one place to fix or
201
+ extend tile-URL construction.
@@ -2,5 +2,5 @@ export { Map } from './MapMdx';
2
2
  export { Map as MapCore } from './Map';
3
3
  export { defaultBasemaps, getBasemap, getBasemaps } from './defaultBasemaps';
4
4
  export type { BasemapKey } from './defaultBasemaps';
5
- export type { VectorLayerConfig } from './types';
5
+ export type { OverlayLayerConfig } from './types';
6
6
  export * from './types';
@@ -71,30 +71,62 @@ export interface BaseLayerConfig {
71
71
  name: string;
72
72
  url: string;
73
73
  attribution?: string;
74
+ /** Tile size in pixels, for services that don't use 256px tiles (default: 256) */
75
+ tileSize?: number;
74
76
  }
75
77
 
76
- export interface VectorLayerConfig {
78
+ /**
79
+ * XYZ raster tile source — for a raster layer used as an independent,
80
+ * toggleable overlay rather than a radio-exclusive basemap.
81
+ */
82
+ export interface XyzSourceConfig {
83
+ type: 'xyz';
84
+ /** Tile URL template with {z}/{x}/{y}, optionally {s} (expanded to a/b/c) */
85
+ url: string;
86
+ tileSize?: number;
87
+ attribution?: string;
88
+ }
89
+
90
+ /**
91
+ * WMS raster source — builds a GetMap tile URL template from friendly params.
92
+ */
93
+ export interface WmsSourceConfig {
94
+ type: 'wms';
95
+ /** WMS endpoint base URL */
96
+ url: string;
97
+ /** GetMap LAYERS param */
98
+ layers: string;
99
+ format?: string;
100
+ version?: string;
101
+ transparent?: boolean;
102
+ styles?: string;
103
+ crs?: string;
104
+ tileSize?: number;
105
+ attribution?: string;
106
+ }
107
+
108
+ export interface OverlayLayerConfig {
77
109
  /** Layer name/ID */
78
110
  name: string;
79
-
111
+
80
112
  /** Data source configuration */
81
- source: SourceConfig;
82
-
113
+ source: SourceConfig | XyzSourceConfig | WmsSourceConfig;
114
+
83
115
  /** Layer style (MapLibre paint/layout properties) */
84
116
  style?: any;
85
-
117
+
86
118
  /** Popup template string (e.g., "<b>${Title}</b>: ${Description}") */
87
119
  popupTemplate?: string;
88
-
120
+
89
121
  /** Initial visibility */
90
122
  visible?: boolean;
91
-
123
+
92
124
  /** Fit map bounds to layer content */
93
125
  fitToContent?: boolean;
94
126
 
95
127
  /** Optional client-side filter for features */
96
128
  filter?: FilterObject | ((feature: any) => boolean);
97
-
129
+
98
130
  /** Search configuration - defines which fields are searchable */
99
131
  searchInFields?: SearchInFields;
100
132
  }
@@ -128,8 +160,8 @@ export interface MapProps {
128
160
  /** Base layers configuration (can be array of configs or array of basemap keys) */
129
161
  baseLayers?: BaseLayerConfig[] | BasemapKey[];
130
162
 
131
- /** Vector layers configuration (alternative to children for hydration-safe usage) */
132
- vectorLayers?: VectorLayerConfig[];
163
+ /** Overlay layers configuration (alternative to children for hydration-safe usage) */
164
+ overlayLayers?: OverlayLayerConfig[];
133
165
 
134
166
  /** Controls positions (null/undefined to hide) */
135
167
  geolocateControl?: ControlPosition;
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { DataRow } from '../../utils/data-fetcher';
6
6
  import type { FeatureCollection, Feature, Geometry } from 'geojson';
7
+ import type { WmsSourceConfig } from './types';
7
8
 
8
9
  /**
9
10
  * Tries to identify latitude and longitude fields from a data row
@@ -116,6 +117,49 @@ export function dataToGeoJson(
116
117
  };
117
118
  }
118
119
 
120
+ /**
121
+ * Expands an XYZ tile URL template's {s} subdomain placeholder into the
122
+ * standard a/b/c rotation (as Leaflet does natively). Returns the url
123
+ * unchanged, wrapped in an array, if it has no {s} placeholder.
124
+ */
125
+ export function expandXyzSubdomains(url: string): string[] {
126
+ if (!url.includes('{s}')) return [url];
127
+ return ['a', 'b', 'c'].map(s => url.replace('{s}', s));
128
+ }
129
+
130
+ /**
131
+ * Builds a WMS GetMap tile URL template from friendly params, using
132
+ * MapLibre's {bbox-epsg-3857} placeholder for the per-tile bounding box.
133
+ */
134
+ export function buildWmsUrl(source: WmsSourceConfig): string {
135
+ const {
136
+ url,
137
+ layers,
138
+ format = 'image/png',
139
+ version = '1.3.0',
140
+ transparent = true,
141
+ styles = '',
142
+ crs = 'EPSG:3857',
143
+ tileSize = 256
144
+ } = source;
145
+
146
+ const params = new URLSearchParams({
147
+ service: 'WMS',
148
+ request: 'GetMap',
149
+ version,
150
+ layers,
151
+ styles,
152
+ format,
153
+ transparent: String(transparent),
154
+ width: String(tileSize),
155
+ height: String(tileSize),
156
+ crs
157
+ });
158
+
159
+ const separator = url.includes('?') ? '&' : '?';
160
+ return `${url}${separator}${params.toString()}&bbox={bbox-epsg-3857}`;
161
+ }
162
+
119
163
  /**
120
164
  * Escapes a string for safe interpolation into HTML markup/attributes.
121
165
  */
@@ -0,0 +1,119 @@
1
+ # RedirectPage Component
2
+
3
+ A static, client-side redirect stub for static hosts (GitHub Pages, S3, any
4
+ CDN without server-side redirect support). Renders a page that redirects via
5
+ `<meta http-equiv="refresh">`, a `<link rel="canonical">` for search engines,
6
+ and a `window.location.replace()` fallback for browsers that ignore
7
+ meta-refresh.
8
+
9
+ ## When to use this vs. Astro's native `redirects`
10
+
11
+ Astro's [`redirects`](https://docs.astro.build/en/reference/configuration-reference/#redirects)
12
+ config option generates the same kind of stub automatically for `output:
13
+ 'static'` sites, and should be preferred whenever it applies — it's zero
14
+ extra code. Reach for `RedirectPage` only when `redirects` can't express the
15
+ mapping.
16
+
17
+ **Works fine with native `redirects`:** a static source path redirecting to
18
+ a destination produced by a dynamic route, as long as the destination is
19
+ given as a concrete, already-resolved path (not a bracketed route pattern).
20
+ Astro only validates dynamic-parameter propagation when the *source* path
21
+ itself has dynamic segments — a static source skips that check entirely.
22
+ Confirmed empirically against Astro 7.2.4 (`output: 'static'`): a source
23
+ route with no params, e.g. `/chi-siamo`, redirecting to `/it/chi-siamo`
24
+ (itself generated by a `src/pages/[locale]/chi-siamo.astro` with
25
+ `getStaticPaths`), builds and emits the expected redirect stub with no
26
+ errors.
27
+
28
+ ```js
29
+ // astro.config.mjs
30
+ export default defineConfig({
31
+ redirects: {
32
+ '/chi-siamo': '/it/chi-siamo',
33
+ '/notizie': '/it/notizie',
34
+ },
35
+ });
36
+ ```
37
+
38
+ **Needs `RedirectPage` instead:** the source path itself is dynamic — e.g.
39
+ one redirect per old article slug, `/notizie/<slug>` → `/it/notizie/<slug>`
40
+ — *and* the destination route has a dynamic parameter the source doesn't
41
+ carry (typically a locale prefix). Astro requires the destination's dynamic
42
+ parameters to already be present in the source; `[locale]` isn't, since the
43
+ old flat URL had no locale segment. Trying to force it fails at build time:
44
+
45
+ ```js
46
+ redirects: {
47
+ '/notizie/[...slug]': '/it/notizie/[...slug]', // does NOT match the
48
+ // registered `/[locale]/notizie/[...slug]` route — this throws
49
+ // InvalidRedirectDestination: "The destination ... does not match any
50
+ // existing route in your project."
51
+ }
52
+ ```
53
+
54
+ There's no config-level way to say "always substitute `it` for `[locale]`
55
+ here" — the list of old slugs to redirect, and the mapping to each new URL,
56
+ has to come from actual code that knows about the content collection. That's
57
+ what the recipe below does.
58
+
59
+ ## Basic usage — single static redirect
60
+
61
+ For a handful of one-off redirects where you'd rather not touch
62
+ `astro.config.mjs` (or where the source **is** dynamic but happens not to
63
+ need a config-level `redirects` entry), drop a page using `RedirectPage`
64
+ directly:
65
+
66
+ ```astro
67
+ ---
68
+ // src/pages/chi-siamo.astro
69
+ import { RedirectPage } from '@lad-sapienza/scms-core';
70
+ export const prerender = true;
71
+ ---
72
+ <RedirectPage target="/it/chi-siamo" />
73
+ ```
74
+
75
+ ## Recipe — redirecting every entry of a content collection
76
+
77
+ The common case: a collection is moving from a flat URL (`/notizie/<slug>/`)
78
+ to a locale-prefixed one (`/it/notizie/<slug>/`), and every existing entry
79
+ needs its own redirect stub. Generate one page per old slug with
80
+ `getStaticPaths`, reading the slug list straight from the collection so the
81
+ redirect list can never drift out of sync with the content itself:
82
+
83
+ ```astro
84
+ ---
85
+ // src/pages/notizie/[...slug].astro
86
+ import { RedirectPage } from '@lad-sapienza/scms-core';
87
+ import { getCollection } from 'astro:content';
88
+
89
+ export const prerender = true;
90
+
91
+ export async function getStaticPaths() {
92
+ const articles = await getCollection('notizie');
93
+ // Adapt this to however your entry IDs encode locale/slug — e.g. if
94
+ // content lives under src/content/notizie/it/<slug>/, split the id
95
+ // yourself and keep only the default-locale entries (each locale has the
96
+ // same slug, so the redirect only needs to be generated once).
97
+ return articles
98
+ .filter((a) => a.id.startsWith('it/'))
99
+ .map((a) => ({ params: { slug: a.id.slice('it/'.length) } }));
100
+ }
101
+
102
+ const { slug } = Astro.params;
103
+ ---
104
+ <RedirectPage target={`/it/notizie/${slug}/`} />
105
+ ```
106
+
107
+ Repeat one such `[...slug].astro` per collection that needs migrating
108
+ (`blog`, `ricerca`, `didattica`, ...) — the body only changes in the
109
+ collection name and the target prefix. A collection's own `index.astro`
110
+ (the old flat listing page) is a single static redirect and can either use
111
+ this same component directly or, per the section above, a plain
112
+ `redirects` config entry.
113
+
114
+ ## Props
115
+
116
+ | Prop | Type | Default | Description |
117
+ |---|---|---|---|
118
+ | `target` | `string` | — | Destination URL to redirect to (required) |
119
+ | `title` | `string` | `"Redirecting…"` | Page `<title>`, shown briefly before the redirect fires |
@@ -0,0 +1,34 @@
1
+ ---
2
+ /**
3
+ * Static client-side redirect stub (meta-refresh + JS), for static hosts
4
+ * where server-side redirects aren't available (e.g. GitHub Pages). Render
5
+ * one of these at each old URL to send visitors/search engines/citations to
6
+ * a new location — typically after a URL scheme change (e.g. adding an
7
+ * i18n locale prefix) that Astro's own config-level `redirects` can't
8
+ * express. See `components/RedirectPage/README.md` for when to reach for
9
+ * this instead of `redirects`, and for the `getStaticPaths` recipe that
10
+ * generates one of these per entry of a content collection.
11
+ */
12
+ interface Props {
13
+ /** Destination URL to redirect to */
14
+ target: string
15
+ /** Page title shown briefly before the redirect fires */
16
+ title?: string
17
+ }
18
+ const { target, title = "Redirecting…" } = Astro.props
19
+ ---
20
+ <!doctype html>
21
+ <html lang="en">
22
+ <head>
23
+ <meta charset="UTF-8" />
24
+ <meta http-equiv="refresh" content={`0; url=${target}`} />
25
+ <link rel="canonical" href={target} />
26
+ <title>{title}</title>
27
+ </head>
28
+ <body>
29
+ <p>Redirecting to <a href={target}>{target}</a>…</p>
30
+ <script is:inline define:vars={{ target }}>
31
+ window.location.replace(target);
32
+ </script>
33
+ </body>
34
+ </html>
@@ -360,7 +360,7 @@ export function ZoteroGeoViewer(props: ZoteroGeoViewerProps) {
360
360
  height={mapHeight}
361
361
  center={mapCenter}
362
362
  baseLayers={['EsriSatellite', 'GoogleTerrain', 'Imperium']}
363
- vectorLayers={mapCenter && geojson && geojson.features.length > 0 ? [{
363
+ overlayLayers={mapCenter && geojson && geojson.features.length > 0 ? [{
364
364
  name: t('zoteroGeoViewer.layerName'),
365
365
  source: { type: 'geojson', data: geojson },
366
366
  style: {
package/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Core package exports
2
2
  export { Gallery } from './components/Gallery';
3
3
  export { default as TableOfContents } from './components/TableOfContents/TableOfContents.astro';
4
+ export { default as RedirectPage } from './components/RedirectPage/RedirectPage.astro';
4
5
  export { default as ThemeToggle } from './components/ThemeToggle';
5
6
 
6
7
  // DataTb (recommended table component)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lad-sapienza/scms-core",
3
- "version": "0.1.0-alpha.6",
3
+ "version": "0.1.0-alpha.8",
4
4
  "description": "Framework components and Astro integrations for s:CMS — DataTb, Gallery, Map, ZoteroGeoViewer, Record.",
5
5
  "type": "module",
6
6
  "license": "BSD-0-Clause",