@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,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&hellip;
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 &ldquo;{table}&rdquo; with ID &ldquo;{id}&rdquo;.
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
+