@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.
- package/LICENSE +14 -0
- package/README.md +73 -0
- package/components/BSNavbar.tsx +264 -0
- package/components/DataTb/DataTb.astro +33 -0
- package/components/DataTb/DataTb.tsx +340 -0
- package/components/DataTb/DataTbMdx.tsx +75 -0
- package/components/DataTb/README.md +275 -0
- package/components/DataTb/index.ts +36 -0
- package/components/DataTb/sources/ApiSource.tsx +89 -0
- package/components/DataTb/sources/CsvSource.tsx +69 -0
- package/components/DataTb/sources/DirectusSource.tsx +98 -0
- package/components/DataTb/sources/JsonSource.tsx +65 -0
- package/components/DataTb/sources/index.ts +17 -0
- package/components/DataTb/types.ts +215 -0
- package/components/DataTb/utils.ts +186 -0
- package/components/Gallery/Gallery.astro +65 -0
- package/components/Gallery/Gallery.tsx +136 -0
- package/components/Gallery/README.md +118 -0
- package/components/Gallery/__tests__/galleryUtils.test.ts +178 -0
- package/components/Gallery/galleryUtils.ts +186 -0
- package/components/Gallery/index.ts +7 -0
- package/components/Gallery/react-photoswipe-gallery.d.ts +26 -0
- package/components/Gallery/types.ts +46 -0
- package/components/Gallery/virtual-gallery.d.ts +15 -0
- package/components/Map/LayerControl.tsx +368 -0
- package/components/Map/Map.tsx +735 -0
- package/components/Map/MapMdx.tsx +37 -0
- package/components/Map/RasterLayerLibre.tsx +33 -0
- package/components/Map/Search/SearchOperators.ts +31 -0
- package/components/Map/Search/SearchUI.tsx +75 -0
- package/components/Map/Search/SearchUIAdvanced.tsx +289 -0
- package/components/Map/Search/SearchUISimple.tsx +115 -0
- package/components/Map/Search/index.ts +7 -0
- package/components/Map/defaultBasemaps.ts +88 -0
- package/components/Map/index.ts +6 -0
- package/components/Map/types.ts +179 -0
- package/components/Map/utils.ts +367 -0
- package/components/README.md +62 -0
- package/components/Record/Field.tsx +35 -0
- package/components/Record/Image.tsx +114 -0
- package/components/Record/RecordFetcher.tsx +127 -0
- package/components/Record/context.tsx +14 -0
- package/components/Record/index.ts +11 -0
- package/components/Record/useRecordFetcher.ts +104 -0
- package/components/Record/utils.ts +13 -0
- package/components/SEO/SEO.astro +132 -0
- package/components/TableOfContents/TableOfContents.astro +86 -0
- package/components/ZoteroGeoViewer/README.md +195 -0
- package/components/ZoteroGeoViewer/ZoteroGeoViewer.astro +26 -0
- package/components/ZoteroGeoViewer/ZoteroGeoViewer.tsx +483 -0
- package/components/ZoteroGeoViewer/ZoteroRecordsPreview.tsx +185 -0
- package/components/ZoteroGeoViewer/index.ts +16 -0
- package/components/ZoteroGeoViewer/types.ts +123 -0
- package/index.ts +26 -0
- package/integrations/__tests__/galleryIntegration.test.ts +70 -0
- package/integrations/contentAssetsIntegration.ts +132 -0
- package/integrations/directusLoader.ts +98 -0
- package/integrations/galleryIntegration.ts +78 -0
- package/package.json +56 -0
- package/scms.ts +95 -0
- package/utils/content.ts +88 -0
- package/utils/data-fetcher.ts +128 -0
- package/utils/directus-config.ts +160 -0
- package/utils/record-fetcher.ts +132 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useContext } from 'react';
|
|
2
|
+
import { RecordContext } from './context';
|
|
3
|
+
import { getValueByDotPath } from './utils';
|
|
4
|
+
|
|
5
|
+
interface DirectusFileRef {
|
|
6
|
+
id?: string;
|
|
7
|
+
filename_download?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ImageProps {
|
|
13
|
+
fieldName: string;
|
|
14
|
+
index?: number | 'all';
|
|
15
|
+
dEndPoint?: string;
|
|
16
|
+
preset?: string | null;
|
|
17
|
+
custom?: string | null;
|
|
18
|
+
className?: string;
|
|
19
|
+
alt?: string;
|
|
20
|
+
record?: any;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function asFileRef(item: any): DirectusFileRef | null {
|
|
24
|
+
if (!item) return null;
|
|
25
|
+
|
|
26
|
+
if (item.directus_files_id && typeof item.directus_files_id === 'object') {
|
|
27
|
+
return item.directus_files_id;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (typeof item === 'object' && item.id) {
|
|
31
|
+
return item;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildImageUrl(
|
|
38
|
+
endpoint: string,
|
|
39
|
+
file: DirectusFileRef,
|
|
40
|
+
preset?: string | null,
|
|
41
|
+
custom?: string | null,
|
|
42
|
+
): string {
|
|
43
|
+
const base = `${endpoint.replace(/\/$/, '')}/assets/${file.id}${file.filename_download ? `/${file.filename_download}` : ''}`;
|
|
44
|
+
|
|
45
|
+
if (preset) return `${base}?key=${encodeURIComponent(preset)}`;
|
|
46
|
+
if (custom) return `${base}?${custom}`;
|
|
47
|
+
return base;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function Image({
|
|
51
|
+
fieldName,
|
|
52
|
+
index = 0,
|
|
53
|
+
dEndPoint = import.meta.env.PUBLIC_DIRECTUS_URL,
|
|
54
|
+
preset = null,
|
|
55
|
+
custom = null,
|
|
56
|
+
className,
|
|
57
|
+
alt,
|
|
58
|
+
record,
|
|
59
|
+
}: ImageProps) {
|
|
60
|
+
const contextRecord = useContext(RecordContext);
|
|
61
|
+
const sourceRecord = record ?? contextRecord;
|
|
62
|
+
|
|
63
|
+
if (!sourceRecord) return null;
|
|
64
|
+
|
|
65
|
+
const rawValue = getValueByDotPath(sourceRecord, fieldName);
|
|
66
|
+
if (!rawValue) return null;
|
|
67
|
+
|
|
68
|
+
const list = Array.isArray(rawValue) ? rawValue : [rawValue];
|
|
69
|
+
|
|
70
|
+
// Render images: handle both plain URLs and Directus file objects
|
|
71
|
+
const renderItems = list.map((item, idx) => {
|
|
72
|
+
// Handle plain URL strings
|
|
73
|
+
if (typeof item === 'string' && (item.startsWith('http') || item.startsWith('/'))) {
|
|
74
|
+
return (
|
|
75
|
+
<img
|
|
76
|
+
key={idx}
|
|
77
|
+
src={item}
|
|
78
|
+
alt={alt || fieldName}
|
|
79
|
+
className={className}
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Handle Directus file objects
|
|
85
|
+
const file = asFileRef(item);
|
|
86
|
+
if (!file?.id) return null;
|
|
87
|
+
|
|
88
|
+
if (!dEndPoint) return null;
|
|
89
|
+
|
|
90
|
+
const src = buildImageUrl(dEndPoint, file, preset, custom);
|
|
91
|
+
const imageAlt = alt || file.description || file.title || file.filename_download || '';
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<img
|
|
95
|
+
key={idx}
|
|
96
|
+
src={src}
|
|
97
|
+
alt={imageAlt}
|
|
98
|
+
className={className}
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const validImages = renderItems.filter(Boolean);
|
|
104
|
+
if (validImages.length === 0) return null;
|
|
105
|
+
|
|
106
|
+
if (index === 'all') {
|
|
107
|
+
return <>{validImages}</>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return validImages[index] || validImages[0];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
export default Image;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import type { ComponentType } from 'react';
|
|
3
|
+
|
|
4
|
+
export interface RecordFetcherProps {
|
|
5
|
+
/** Directus base URL. Defaults to PUBLIC_DIRECTUS_URL env var. */
|
|
6
|
+
directusUrl?: string;
|
|
7
|
+
/** Directus static token for authentication. Defaults to PUBLIC_DIRECTUS_TOKEN env var. */
|
|
8
|
+
directusToken?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Fields to fetch (Directus fields query, e.g. `"*.*.*"`).
|
|
11
|
+
* Defaults to `"*.*.*"`.
|
|
12
|
+
*/
|
|
13
|
+
fields?: string;
|
|
14
|
+
/**
|
|
15
|
+
* User-provided layout component that receives the fetched `record` as a prop.
|
|
16
|
+
* It also receives `table` and `id` for context.
|
|
17
|
+
*/
|
|
18
|
+
Layout: ComponentType<{ record: any; table: string; id: string }>;
|
|
19
|
+
/** Optional custom loading component. */
|
|
20
|
+
LoadingComponent?: ComponentType;
|
|
21
|
+
/** Optional custom error component. */
|
|
22
|
+
ErrorComponent?: ComponentType<{ table: string; id: string }>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function DefaultLoading() {
|
|
26
|
+
return (
|
|
27
|
+
<div style={{ padding: '2rem', opacity: 0.5 }}>
|
|
28
|
+
Loading record…
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function DefaultError({ table, id }: { table: string; id: string }) {
|
|
34
|
+
return (
|
|
35
|
+
<div style={{ padding: '2rem', color: 'red' }}>
|
|
36
|
+
Record not found for table “{table}” with ID “{id}”.
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Core component that reads `table`, `id`, and `fields` from the URL query
|
|
43
|
+
* string, fetches the matching Directus record, and renders the user-supplied
|
|
44
|
+
* `Layout` component with the resolved `record` object.
|
|
45
|
+
*
|
|
46
|
+
* **Usage in an Astro page:**
|
|
47
|
+
* ```astro
|
|
48
|
+
* ---
|
|
49
|
+
* import RecordFetcher from '@lad-sapienza/scms-core/components/Record/RecordFetcher.tsx';
|
|
50
|
+
* import MyLayout from '@components/MyRecordLayout.tsx';
|
|
51
|
+
* ---
|
|
52
|
+
* <RecordFetcher Layout={MyLayout} client:load />
|
|
53
|
+
* ```
|
|
54
|
+
*
|
|
55
|
+
* **User layout** only needs to accept `record`:
|
|
56
|
+
* ```tsx
|
|
57
|
+
* export default function MyRecordLayout({ record }) {
|
|
58
|
+
* return <h1>{record.title}</h1>;
|
|
59
|
+
* }
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* URL query parameters recognised:
|
|
63
|
+
* - `table` — Directus collection name (required)
|
|
64
|
+
* - `id` — Record primary key (required)
|
|
65
|
+
* - `fields` — Directus fields selector (optional, overrides the `fields` prop)
|
|
66
|
+
*/
|
|
67
|
+
export function RecordFetcher({
|
|
68
|
+
directusUrl = import.meta.env.PUBLIC_DIRECTUS_URL ?? '',
|
|
69
|
+
directusToken = import.meta.env.PUBLIC_DIRECTUS_TOKEN ?? '',
|
|
70
|
+
fields: fieldsProp = '*.*.*',
|
|
71
|
+
Layout,
|
|
72
|
+
LoadingComponent = DefaultLoading,
|
|
73
|
+
ErrorComponent = DefaultError,
|
|
74
|
+
}: RecordFetcherProps) {
|
|
75
|
+
const [record, setRecord] = useState<any>(null);
|
|
76
|
+
const [loading, setLoading] = useState(true);
|
|
77
|
+
const [error, setError] = useState(false);
|
|
78
|
+
const [table, setTable] = useState('');
|
|
79
|
+
const [id, setId] = useState('');
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const params = new URLSearchParams(window.location.search);
|
|
83
|
+
const actualTable = params.get('table') ?? '';
|
|
84
|
+
const actualId = params.get('id') ?? '';
|
|
85
|
+
const fields = params.get('fields') ?? fieldsProp;
|
|
86
|
+
|
|
87
|
+
setTable(actualTable);
|
|
88
|
+
setId(actualId);
|
|
89
|
+
|
|
90
|
+
if (!actualTable || !actualId) {
|
|
91
|
+
setError(true);
|
|
92
|
+
setLoading(false);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const url = `${directusUrl.replace(/\/$/, '')}/items/${actualTable}/${actualId}?fields=${encodeURIComponent(fields)}`;
|
|
97
|
+
|
|
98
|
+
const headers: Record<string, string> = {
|
|
99
|
+
'Content-Type': 'application/json',
|
|
100
|
+
};
|
|
101
|
+
if (directusToken) {
|
|
102
|
+
headers['Authorization'] = `Bearer ${directusToken}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fetch(url, { headers })
|
|
106
|
+
.then(r => {
|
|
107
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
108
|
+
return r.json();
|
|
109
|
+
})
|
|
110
|
+
.then(data => {
|
|
111
|
+
setRecord(data.data ?? data);
|
|
112
|
+
setLoading(false);
|
|
113
|
+
})
|
|
114
|
+
.catch(err => {
|
|
115
|
+
console.error('RecordFetcher: error fetching record:', err);
|
|
116
|
+
setError(true);
|
|
117
|
+
setLoading(false);
|
|
118
|
+
});
|
|
119
|
+
}, [directusUrl, directusToken, fieldsProp]);
|
|
120
|
+
|
|
121
|
+
if (loading) return <LoadingComponent />;
|
|
122
|
+
if (error || !record) return <ErrorComponent table={table} id={id} />;
|
|
123
|
+
|
|
124
|
+
return <Layout record={record} table={table} id={id} />;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export default RecordFetcher;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export const RecordContext = React.createContext<any>(null);
|
|
4
|
+
|
|
5
|
+
export interface RecordProviderProps {
|
|
6
|
+
record: any;
|
|
7
|
+
children?: React.ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function RecordProvider({ record, children }: RecordProviderProps) {
|
|
11
|
+
return <RecordContext.Provider value={record}>{children}</RecordContext.Provider>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default RecordProvider;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { RecordContext, RecordProvider } from './context';
|
|
2
|
+
export { Field } from './Field';
|
|
3
|
+
export { Image } from './Image';
|
|
4
|
+
export { RecordFetcher } from './RecordFetcher';
|
|
5
|
+
export { useRecordFetcher } from './useRecordFetcher';
|
|
6
|
+
export { getValueByDotPath } from './utils';
|
|
7
|
+
export type { RecordProviderProps } from './context';
|
|
8
|
+
export type { FieldProps } from './Field';
|
|
9
|
+
export type { ImageProps } from './Image';
|
|
10
|
+
export type { RecordFetcherProps } from './RecordFetcher';
|
|
11
|
+
export type { UseRecordFetcherOptions, UseRecordFetcherResult } from './useRecordFetcher';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface UseRecordFetcherOptions {
|
|
4
|
+
/** Directus base URL. Defaults to PUBLIC_DIRECTUS_URL env var. */
|
|
5
|
+
directusUrl?: string;
|
|
6
|
+
/** Directus static token. Defaults to PUBLIC_DIRECTUS_TOKEN env var. */
|
|
7
|
+
directusToken?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Fields to fetch (Directus fields query).
|
|
10
|
+
* Can be overridden per-request by a `fields` URL query parameter.
|
|
11
|
+
* Defaults to `"*.*.*"`.
|
|
12
|
+
*/
|
|
13
|
+
fields?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UseRecordFetcherResult {
|
|
17
|
+
record: any | null;
|
|
18
|
+
loading: boolean;
|
|
19
|
+
error: boolean;
|
|
20
|
+
table: string;
|
|
21
|
+
id: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* React hook that reads `table`, `id`, and `fields` from the URL query string
|
|
26
|
+
* and fetches the matching Directus record.
|
|
27
|
+
*
|
|
28
|
+
* Designed to be called inside a user component that is itself hydrated by
|
|
29
|
+
* Astro (`client:load` / `client:idle` etc.), avoiding the limitation that
|
|
30
|
+
* Astro cannot serialize React components across the hydration boundary.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```tsx
|
|
34
|
+
* // usr/components/RecordView.tsx
|
|
35
|
+
* import { useRecordFetcher } from '@lad-sapienza/scms-core/components/Record/useRecordFetcher';
|
|
36
|
+
*
|
|
37
|
+
* export default function RecordView() {
|
|
38
|
+
* const { record, loading, error, table, id } = useRecordFetcher();
|
|
39
|
+
*
|
|
40
|
+
* if (loading) return <p>Loading…</p>;
|
|
41
|
+
* if (error || !record) return <p>Record not found.</p>;
|
|
42
|
+
*
|
|
43
|
+
* return <h1>{record.title}</h1>;
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* ```astro
|
|
48
|
+
* <!-- usr/pages/record.astro -->
|
|
49
|
+
* <RecordView client:load />
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function useRecordFetcher({
|
|
53
|
+
directusUrl = import.meta.env.PUBLIC_DIRECTUS_URL ?? '',
|
|
54
|
+
directusToken = import.meta.env.PUBLIC_DIRECTUS_TOKEN ?? '',
|
|
55
|
+
fields: fieldsProp = '*.*.*',
|
|
56
|
+
}: UseRecordFetcherOptions = {}): UseRecordFetcherResult {
|
|
57
|
+
const [record, setRecord] = useState<any>(null);
|
|
58
|
+
const [loading, setLoading] = useState(true);
|
|
59
|
+
const [error, setError] = useState(false);
|
|
60
|
+
const [table, setTable] = useState('');
|
|
61
|
+
const [id, setId] = useState('');
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const params = new URLSearchParams(window.location.search);
|
|
65
|
+
const actualTable = params.get('table') ?? '';
|
|
66
|
+
const actualId = params.get('id') ?? '';
|
|
67
|
+
const fields = params.get('fields') ?? fieldsProp;
|
|
68
|
+
|
|
69
|
+
setTable(actualTable);
|
|
70
|
+
setId(actualId);
|
|
71
|
+
|
|
72
|
+
if (!actualTable || !actualId) {
|
|
73
|
+
setError(true);
|
|
74
|
+
setLoading(false);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const url = `${directusUrl.replace(/\/$/, '')}/items/${actualTable}/${actualId}?fields=${encodeURIComponent(fields)}`;
|
|
79
|
+
|
|
80
|
+
const headers: Record<string, string> = {
|
|
81
|
+
'Content-Type': 'application/json',
|
|
82
|
+
};
|
|
83
|
+
if (directusToken) {
|
|
84
|
+
headers['Authorization'] = `Bearer ${directusToken}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fetch(url, { headers })
|
|
88
|
+
.then(r => {
|
|
89
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
90
|
+
return r.json();
|
|
91
|
+
})
|
|
92
|
+
.then(data => {
|
|
93
|
+
setRecord(data.data ?? data);
|
|
94
|
+
setLoading(false);
|
|
95
|
+
})
|
|
96
|
+
.catch(err => {
|
|
97
|
+
console.error('useRecordFetcher: error fetching record:', err);
|
|
98
|
+
setError(true);
|
|
99
|
+
setLoading(false);
|
|
100
|
+
});
|
|
101
|
+
}, [directusUrl, directusToken, fieldsProp]);
|
|
102
|
+
|
|
103
|
+
return { record, loading, error, table, id };
|
|
104
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function getValueByDotPath(data: any, path: string): any {
|
|
2
|
+
if (!data || !path) return undefined;
|
|
3
|
+
|
|
4
|
+
return path.split('.').reduce((current, segment) => {
|
|
5
|
+
if (current == null) return undefined;
|
|
6
|
+
|
|
7
|
+
if (Array.isArray(current) && /^\d+$/.test(segment)) {
|
|
8
|
+
return current[Number(segment)];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return current[segment];
|
|
12
|
+
}, data);
|
|
13
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* SEO Component for Astro
|
|
4
|
+
* Based on the original sCMS SEO implementation
|
|
5
|
+
*
|
|
6
|
+
* This component generates meta tags and structured data for SEO
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
interface SiteMetadata {
|
|
10
|
+
title: string;
|
|
11
|
+
titleTemplate?: string;
|
|
12
|
+
description: string;
|
|
13
|
+
defaultImage?: string;
|
|
14
|
+
siteName?: string;
|
|
15
|
+
twitter?: string;
|
|
16
|
+
author?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
title?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
image?: string;
|
|
23
|
+
author?: string;
|
|
24
|
+
isArticle?: boolean;
|
|
25
|
+
datePublished?: string | Date;
|
|
26
|
+
dateModified?: string | Date;
|
|
27
|
+
tags?: string[];
|
|
28
|
+
canonical?: string;
|
|
29
|
+
siteMetadata?: SiteMetadata;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const {
|
|
33
|
+
title,
|
|
34
|
+
description,
|
|
35
|
+
image,
|
|
36
|
+
author,
|
|
37
|
+
isArticle = false,
|
|
38
|
+
datePublished,
|
|
39
|
+
dateModified,
|
|
40
|
+
tags = [],
|
|
41
|
+
canonical,
|
|
42
|
+
siteMetadata,
|
|
43
|
+
} = Astro.props;
|
|
44
|
+
|
|
45
|
+
// Get site metadata from props or use defaults
|
|
46
|
+
const meta = siteMetadata || {
|
|
47
|
+
title: 's:CMS',
|
|
48
|
+
description: 'Static Content Management System',
|
|
49
|
+
defaultImage: '/images/scms-lad.png',
|
|
50
|
+
siteName: 'LAD: Laboratorio di Archeologia Digitale alla Sapienza',
|
|
51
|
+
twitter: '@JulianBogdani',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Get site URL
|
|
55
|
+
const siteUrl = Astro.site?.toString() || 'https://example.com';
|
|
56
|
+
const currentUrl = canonical || new URL(Astro.url.pathname, siteUrl).toString();
|
|
57
|
+
|
|
58
|
+
// Resolve values
|
|
59
|
+
const resolvedTitle = title || meta.title;
|
|
60
|
+
const resolvedDescription = description || meta.description;
|
|
61
|
+
const resolvedImage = image ? new URL(image, siteUrl).toString() : (meta.defaultImage ? new URL(meta.defaultImage, siteUrl).toString() : undefined);
|
|
62
|
+
const pageTitle = meta.titleTemplate ? meta.titleTemplate.replace('%s', resolvedTitle) : resolvedTitle;
|
|
63
|
+
|
|
64
|
+
// Format dates
|
|
65
|
+
const formatDate = (date?: string | Date) => {
|
|
66
|
+
if (!date) return null;
|
|
67
|
+
const d = typeof date === 'string' ? new Date(date) : date;
|
|
68
|
+
return d ? d.toISOString() : '';
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const pubDate = formatDate(datePublished);
|
|
72
|
+
const modDate = formatDate(dateModified);
|
|
73
|
+
|
|
74
|
+
// Build Article JSON-LD for structured data
|
|
75
|
+
const articleJsonLd = isArticle ? {
|
|
76
|
+
'@context': 'https://schema.org',
|
|
77
|
+
'@type': 'Article',
|
|
78
|
+
mainEntityOfPage: {
|
|
79
|
+
'@type': 'WebPage',
|
|
80
|
+
'@id': currentUrl,
|
|
81
|
+
},
|
|
82
|
+
headline: resolvedTitle,
|
|
83
|
+
description: resolvedDescription,
|
|
84
|
+
image: resolvedImage ? [resolvedImage] : [],
|
|
85
|
+
author: author ? [{ '@type': 'Person', name: author }] : (meta.author ? [{ '@type': 'Person', name: meta.author }] : []),
|
|
86
|
+
publisher: {
|
|
87
|
+
'@type': 'Organization',
|
|
88
|
+
name: meta.siteName || meta.title,
|
|
89
|
+
logo: {
|
|
90
|
+
'@type': 'ImageObject',
|
|
91
|
+
url: meta.defaultImage ? new URL(meta.defaultImage, siteUrl).toString() : undefined,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
datePublished: pubDate,
|
|
95
|
+
dateModified: modDate,
|
|
96
|
+
keywords: tags.length ? tags.join(', ') : undefined,
|
|
97
|
+
} : null;
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
<!-- Primary Meta Tags -->
|
|
101
|
+
<title>{pageTitle}</title>
|
|
102
|
+
<meta name="title" content={resolvedTitle} />
|
|
103
|
+
<meta name="description" content={resolvedDescription} />
|
|
104
|
+
<link rel="canonical" href={currentUrl} />
|
|
105
|
+
|
|
106
|
+
<!-- Open Graph / Facebook -->
|
|
107
|
+
<meta property="og:type" content={isArticle ? 'article' : 'website'} />
|
|
108
|
+
<meta property="og:url" content={currentUrl} />
|
|
109
|
+
<meta property="og:title" content={resolvedTitle} />
|
|
110
|
+
<meta property="og:description" content={resolvedDescription} />
|
|
111
|
+
<meta property="og:site_name" content={meta.siteName || meta.title} />
|
|
112
|
+
{resolvedImage && <meta property="og:image" content={resolvedImage} />}
|
|
113
|
+
|
|
114
|
+
<!-- Twitter -->
|
|
115
|
+
<meta property="twitter:card" content={resolvedImage ? 'summary_large_image' : 'summary'} />
|
|
116
|
+
<meta property="twitter:url" content={currentUrl} />
|
|
117
|
+
<meta property="twitter:title" content={resolvedTitle} />
|
|
118
|
+
<meta property="twitter:description" content={resolvedDescription} />
|
|
119
|
+
{resolvedImage && <meta property="twitter:image" content={resolvedImage} />}
|
|
120
|
+
{meta.twitter && <meta name="twitter:site" content={meta.twitter} />}
|
|
121
|
+
{meta.twitter && <meta name="twitter:creator" content={meta.twitter} />}
|
|
122
|
+
|
|
123
|
+
<!-- Article Metadata -->
|
|
124
|
+
{isArticle && author && <meta name="author" content={author} />}
|
|
125
|
+
{isArticle && pubDate && <meta property="article:published_time" content={pubDate} />}
|
|
126
|
+
{isArticle && modDate && <meta property="article:modified_time" content={modDate} />}
|
|
127
|
+
{isArticle && tags.map(tag => <meta property="article:tag" content={tag} />)}
|
|
128
|
+
|
|
129
|
+
<!-- Structured Data -->
|
|
130
|
+
{articleJsonLd && (
|
|
131
|
+
<script type="application/ld+json" set:html={JSON.stringify(articleJsonLd)} is:inline />
|
|
132
|
+
)}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Table of Contents component that extracts headings from the page
|
|
4
|
+
* Automatically generates TOC from all headings with IDs in the page
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
/** Maximum heading level to include (1-6) */
|
|
9
|
+
maxDepth?: number;
|
|
10
|
+
/** Minimum heading level to include (1-6) */
|
|
11
|
+
minDepth?: number;
|
|
12
|
+
/** Title for the TOC section */
|
|
13
|
+
title?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { maxDepth = 3, minDepth = 2, title = "Table of Contents" } = Astro.props;
|
|
17
|
+
|
|
18
|
+
// Generate unique ID for this TOC instance
|
|
19
|
+
const tocId = `toc-${Math.random().toString(36).substring(7)}`;
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<nav id={tocId} class="toc bg-light border border-secondary rounded p-3 mb-4 shadow-sm">
|
|
23
|
+
<h2 class="h5 mb-3 text-secondary">{title}</h2>
|
|
24
|
+
<ul class="toc-list small"></ul>
|
|
25
|
+
</nav>
|
|
26
|
+
|
|
27
|
+
<style>
|
|
28
|
+
.toc {
|
|
29
|
+
background-color: var(--bs-light);
|
|
30
|
+
border-color: var(--bs-secondary);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
a.toc-link {
|
|
34
|
+
color: var(--bs-secondary);
|
|
35
|
+
text-decoration: none;
|
|
36
|
+
transition: color 0.15s ease-in-out;
|
|
37
|
+
display: block;
|
|
38
|
+
padding: 0.25rem 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
a.toc-link:hover {
|
|
42
|
+
color: var(--bs-primary);
|
|
43
|
+
text-decoration: underline;
|
|
44
|
+
}
|
|
45
|
+
</style>
|
|
46
|
+
|
|
47
|
+
<script define:vars={{ tocId, minDepth, maxDepth }} is:inline>
|
|
48
|
+
// Wait for DOM to be ready
|
|
49
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
50
|
+
const tocElement = document.getElementById(tocId);
|
|
51
|
+
if (!tocElement) return;
|
|
52
|
+
|
|
53
|
+
const tocList = tocElement.querySelector('.toc-list');
|
|
54
|
+
if (!tocList) return;
|
|
55
|
+
|
|
56
|
+
// Find all headings with IDs in the document
|
|
57
|
+
const headingSelector = Array.from({ length: maxDepth - minDepth + 1 }, (_, i) =>
|
|
58
|
+
`h${minDepth + i}[id]`
|
|
59
|
+
).join(', ');
|
|
60
|
+
|
|
61
|
+
const headings = document.querySelectorAll(headingSelector);
|
|
62
|
+
|
|
63
|
+
if (headings.length === 0) {
|
|
64
|
+
tocElement.style.display = 'none';
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Build the TOC
|
|
69
|
+
headings.forEach((heading) => {
|
|
70
|
+
// ...existing code...
|
|
71
|
+
const id = heading.id;
|
|
72
|
+
const text = heading.textContent || '';
|
|
73
|
+
|
|
74
|
+
const li = document.createElement('li');
|
|
75
|
+
|
|
76
|
+
const a = document.createElement('a');
|
|
77
|
+
a.href = `#${id}`;
|
|
78
|
+
a.textContent = text;
|
|
79
|
+
a.className = 'toc-link';
|
|
80
|
+
|
|
81
|
+
li.appendChild(a);
|
|
82
|
+
tocList.appendChild(li);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
</script>
|
|
86
|
+
|