@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,179 @@
1
+ /**
2
+ * Map Component Types
3
+ */
4
+
5
+ import type { SourceConfig } from '../../utils/data-fetcher';
6
+ import type { BasemapKey } from './defaultBasemaps';
7
+ import type { FilterObject } from './utils';
8
+ import type { DirectusShorthand } from '../../utils/directus-config';
9
+
10
+ /**
11
+ * Search field configuration
12
+ * Can be a simple label string or an object with label and predefined values
13
+ */
14
+ export type SearchFieldConfig =
15
+ | string
16
+ | {
17
+ label: string;
18
+ values?: string[];
19
+ operator?: string;
20
+ };
21
+
22
+ /**
23
+ * Search fields configuration for vector layers
24
+ * Maps field names to their search configuration
25
+ */
26
+ export type SearchInFields = Record<string, SearchFieldConfig>;
27
+
28
+ /**
29
+ * Search operators supported by the search interface
30
+ */
31
+ export interface SearchOperators {
32
+ _eq: string;
33
+ _neq: string;
34
+ _lt: string;
35
+ _lte: string;
36
+ _gt: string;
37
+ _gte: string;
38
+ _null: string;
39
+ _nnull: string;
40
+ _contains: string;
41
+ _icontains: string;
42
+ _ncontains: string;
43
+ _starts_with: string;
44
+ _istarts_with: string;
45
+ _nstarts_with: string;
46
+ _ends_with: string;
47
+ _iends_with: string;
48
+ _nends_with: string;
49
+ }
50
+
51
+ /**
52
+ * Search filter triplet: field, operator, value
53
+ */
54
+ export interface SearchFilter {
55
+ field: string;
56
+ operator: string;
57
+ value: any;
58
+ }
59
+
60
+ /**
61
+ * Search query structure
62
+ */
63
+ export interface SearchQuery {
64
+ connector: '_and' | '_or';
65
+ filters: SearchFilter[];
66
+ }
67
+
68
+ export type ControlPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
69
+
70
+ export interface BaseLayerConfig {
71
+ name: string;
72
+ url: string;
73
+ attribution?: string;
74
+ }
75
+
76
+ export interface VectorLayerConfig {
77
+ /** Layer name/ID */
78
+ name: string;
79
+
80
+ /** Data source configuration */
81
+ source: SourceConfig;
82
+
83
+ /** Layer style (MapLibre paint/layout properties) */
84
+ style?: any;
85
+
86
+ /** Popup template string (e.g., "<b>${Title}</b>: ${Description}") */
87
+ popupTemplate?: string;
88
+
89
+ /** Initial visibility */
90
+ visible?: boolean;
91
+
92
+ /** Fit map bounds to layer content */
93
+ fitToContent?: boolean;
94
+
95
+ /** Optional client-side filter for features */
96
+ filter?: FilterObject | ((feature: any) => boolean);
97
+
98
+ /** Search configuration - defines which fields are searchable */
99
+ searchInFields?: SearchInFields;
100
+ }
101
+
102
+ export interface MapProps {
103
+ /** Map height (CSS string) */
104
+ height?: string;
105
+
106
+ /** Initial center [lng, lat, zoom] as string "lng,lat,zoom" */
107
+ center?: string;
108
+
109
+ /** MapLibre style object or URL (for full style specification) */
110
+ mapStyle?: string | object;
111
+
112
+ /** Overrides/expansions for mapStyle layers */
113
+ styleOverrides?: {
114
+ [layerId: string]: {
115
+ /** Override layer paint properties */
116
+ paint?: any;
117
+ /** Override layer layout properties */
118
+ layout?: any;
119
+ /** Add popup template (expanded feature) */
120
+ popupTemplate?: string;
121
+ /** Fit map to layer bounds (expanded feature) */
122
+ fitToContent?: boolean;
123
+ /** Initial visibility (expanded feature) */
124
+ visible?: boolean;
125
+ };
126
+ };
127
+
128
+ /** Base layers configuration (can be array of configs or array of basemap keys) */
129
+ baseLayers?: BaseLayerConfig[] | BasemapKey[];
130
+
131
+ /** Vector layers configuration (alternative to children for hydration-safe usage) */
132
+ vectorLayers?: VectorLayerConfig[];
133
+
134
+ /** Controls positions (null/undefined to hide) */
135
+ geolocateControl?: ControlPosition;
136
+ fullscreenControl?: ControlPosition;
137
+ navigationControl?: ControlPosition;
138
+ scaleControl?: ControlPosition;
139
+
140
+ /** Show layer control for basemap switching (default: true) */
141
+ layerControl?: boolean | ControlPosition;
142
+
143
+ /** Optional sprite URL */
144
+ sprite?: string;
145
+
146
+ /** GeoJSON shorthand - for simple single-layer maps */
147
+ geojson?: string | object | {
148
+ path: string;
149
+ name?: string;
150
+ style?: any;
151
+ popup?: string;
152
+ fitToContent?: boolean;
153
+ filter?: FilterObject | ((feature: any) => boolean);
154
+ };
155
+
156
+ /** CSV shorthand - for simple single-layer maps */
157
+ csv?: string | {
158
+ path: string;
159
+ lng: string;
160
+ lat: string;
161
+ name?: string;
162
+ style?: any;
163
+ popup?: string;
164
+ fitToContent?: boolean;
165
+ filter?: FilterObject | ((row: any) => boolean);
166
+ };
167
+
168
+ /** JSON shorthand - for simple single-layer maps */
169
+ json?: any[] | string;
170
+
171
+ /** Directus shorthand - for simple single-layer maps */
172
+ directus?: DirectusShorthand & {
173
+ geoField?: string;
174
+ name?: string;
175
+ style?: any;
176
+ popup?: string;
177
+ fitToContent?: boolean;
178
+ };
179
+ }
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Map Utilities
3
+ */
4
+
5
+ import type { DataRow } from '../../utils/data-fetcher';
6
+ import type { FeatureCollection, Feature, Geometry } from 'geojson';
7
+
8
+ /**
9
+ * Tries to identify latitude and longitude fields from a data row
10
+ */
11
+ export function getGeoFields(row: DataRow): { latField: string; lngField: string } | null {
12
+ const keys = Object.keys(row);
13
+
14
+ const latField = keys.find(k => /^(lat|latitude|y)$/i.test(k));
15
+ const lngField = keys.find(k => /^(lng|long|longitude|x)$/i.test(k));
16
+
17
+ if (latField && lngField) {
18
+ return { latField, lngField };
19
+ }
20
+
21
+ return null;
22
+ }
23
+
24
+ /**
25
+ * Converts generic data rows to GeoJSON FeatureCollection
26
+ * Handles both coordinate-based data and data already containing geometry
27
+ */
28
+ export function dataToGeoJson(
29
+ data: DataRow[],
30
+ customLngField?: string,
31
+ customLatField?: string,
32
+ geoField?: string
33
+ ): FeatureCollection {
34
+ const features: Feature[] = [];
35
+
36
+ if (data.length === 0) {
37
+ return { type: 'FeatureCollection', features: [] };
38
+ }
39
+
40
+ // Check if data already has geometry (from GeoJSON source)
41
+ if (data[0]._geometry) {
42
+ data.forEach((row, index) => {
43
+ features.push({
44
+ type: 'Feature',
45
+ id: row._id || row.id || index,
46
+ geometry: row._geometry,
47
+ properties: { ...row, _geometry: undefined, _id: undefined }
48
+ });
49
+ });
50
+ return { type: 'FeatureCollection', features };
51
+ }
52
+
53
+ // Check if geoField is provided (from Directus or other sources)
54
+ if (geoField && data[0][geoField]) {
55
+ data.forEach((row, index) => {
56
+ let geometry = row[geoField];
57
+
58
+ // Parse geometry if it's a string (JSON)
59
+ if (typeof geometry === 'string') {
60
+ try {
61
+ geometry = JSON.parse(geometry);
62
+ } catch (e) {
63
+ console.warn(`Failed to parse geometry field "${geoField}" for row ${index}:`, e);
64
+ return;
65
+ }
66
+ }
67
+
68
+ // Validate geometry object
69
+ if (geometry && typeof geometry === 'object' && geometry.type && geometry.coordinates) {
70
+ features.push({
71
+ type: 'Feature',
72
+ id: row.id || index,
73
+ geometry: geometry as Geometry,
74
+ properties: { ...row, [geoField]: undefined }
75
+ });
76
+ }
77
+ });
78
+ return { type: 'FeatureCollection', features };
79
+ }
80
+
81
+ // If custom fields provided, use them
82
+ let latField = customLatField;
83
+ let lngField = customLngField;
84
+
85
+ // Otherwise, try to detect lat/lng fields
86
+ if (!latField || !lngField) {
87
+ const geoFields = getGeoFields(data[0]);
88
+ if (!geoFields) {
89
+ console.warn('Map: Could not auto-detect simple lat/lng fields in data. GeoJSON conversion failed.');
90
+ return { type: 'FeatureCollection', features: [] };
91
+ }
92
+ latField = latField || geoFields.latField;
93
+ lngField = lngField || geoFields.lngField;
94
+ }
95
+
96
+ data.forEach((row, index) => {
97
+ const lat = Number(row[latField!]);
98
+ const lng = Number(row[lngField!]);
99
+
100
+ if (!isNaN(lat) && !isNaN(lng)) {
101
+ features.push({
102
+ type: 'Feature',
103
+ id: row.id || index,
104
+ geometry: {
105
+ type: 'Point',
106
+ coordinates: [lng, lat]
107
+ },
108
+ properties: row
109
+ });
110
+ }
111
+ });
112
+
113
+ return {
114
+ type: 'FeatureCollection',
115
+ features
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Escapes a string for safe interpolation into HTML markup/attributes.
121
+ */
122
+ function escapeHtml(value: string): string {
123
+ return value
124
+ .replace(/&/g, '&amp;')
125
+ .replace(/</g, '&lt;')
126
+ .replace(/>/g, '&gt;')
127
+ .replace(/"/g, '&quot;')
128
+ .replace(/'/g, '&#39;');
129
+ }
130
+
131
+ /**
132
+ * Parses simple string templates with ${variable} syntax.
133
+ *
134
+ * The template itself is treated as trusted markup (it's author-supplied,
135
+ * e.g. `popupTemplate: '<b>${Title}</b>'`), but each interpolated value comes
136
+ * from feature/row data — which may be third-party or user-submitted content
137
+ * (CSV, GeoJSON, Directus) — and is HTML-escaped before substitution, since
138
+ * the result is rendered via dangerouslySetInnerHTML.
139
+ */
140
+ export function parseStringTemplate(template: string, data: Record<string, any>): string {
141
+ return template.replace(/\$\{([^}]+)\}/g, (_, key) => {
142
+ return data[key] !== undefined ? escapeHtml(String(data[key])) : '';
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Filter object syntax type (similar to Directus filtering)
148
+ *
149
+ * Example:
150
+ * { field: { _eq: 'value' } }
151
+ * { field: { _neq: 'value' } }
152
+ * { field: { _in: ['value1', 'value2'] } }
153
+ * { field: { _contains: 'substring' } }
154
+ * { field: { _gt: 100 }, field2: { _eq: 'value' } } // Multiple conditions (AND)
155
+ */
156
+ export type FilterObject = {
157
+ [field: string]: {
158
+ _eq?: any;
159
+ _neq?: any;
160
+ _in?: any[];
161
+ _nin?: any[];
162
+ _contains?: string;
163
+ _ncontains?: string;
164
+ _gt?: number;
165
+ _gte?: number;
166
+ _lt?: number;
167
+ _lte?: number;
168
+ _null?: boolean;
169
+ _nnull?: boolean;
170
+ };
171
+ };
172
+
173
+ /**
174
+ * Converts a Directus-like filter object to a JavaScript predicate function
175
+ * Supports both GeoJSON features and CSV rows
176
+ */
177
+ export function filterObjectToPredicate(
178
+ filterObj: FilterObject
179
+ ): (item: Feature | DataRow) => boolean {
180
+ return (item: Feature | DataRow) => {
181
+ // Get properties (for GeoJSON features) or use item directly (for CSV rows)
182
+ const properties = 'properties' in item ? item.properties : item;
183
+
184
+ // Check all field conditions (AND logic)
185
+ for (const [field, conditions] of Object.entries(filterObj)) {
186
+ const value = properties?.[field];
187
+
188
+ // Check each operator
189
+ for (const [operator, expected] of Object.entries(conditions)) {
190
+ switch (operator) {
191
+ case '_eq':
192
+ if (value != expected) return false; // Loose equality
193
+ break;
194
+ case '_neq':
195
+ if (value == expected) return false;
196
+ break;
197
+ case '_in':
198
+ if (!Array.isArray(expected) || !expected.includes(value)) return false;
199
+ break;
200
+ case '_nin':
201
+ if (!Array.isArray(expected) || expected.includes(value)) return false;
202
+ break;
203
+ case '_contains':
204
+ if (typeof value !== 'string' || !value.includes(String(expected))) return false;
205
+ break;
206
+ case '_ncontains':
207
+ if (typeof value !== 'string' || value.includes(String(expected))) return false;
208
+ break;
209
+ case '_gt':
210
+ if (Number(value) <= Number(expected)) return false;
211
+ break;
212
+ case '_gte':
213
+ if (Number(value) < Number(expected)) return false;
214
+ break;
215
+ case '_lt':
216
+ if (Number(value) >= Number(expected)) return false;
217
+ break;
218
+ case '_lte':
219
+ if (Number(value) > Number(expected)) return false;
220
+ break;
221
+ case '_null':
222
+ if (expected === true && value != null) return false;
223
+ if (expected === false && value == null) return false;
224
+ break;
225
+ case '_nnull':
226
+ if (expected === true && value == null) return false;
227
+ if (expected === false && value != null) return false;
228
+ break;
229
+ }
230
+ }
231
+ }
232
+
233
+ return true;
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Converts a search query to a MapLibre filter expression
239
+ * This provides better performance than client-side filtering for large datasets
240
+ */
241
+ export function searchQueryToMapLibreFilter(query: import('./types').SearchQuery): any[] | null {
242
+ if (!query.filters || query.filters.length === 0) {
243
+ return null; // No filter
244
+ }
245
+
246
+ const filters = query.filters.map(filter => {
247
+ const { field, operator, value } = filter;
248
+
249
+ switch (operator) {
250
+ case '_eq':
251
+ return ['==', ['get', field], value];
252
+ case '_neq':
253
+ return ['!=', ['get', field], value];
254
+ case '_gt':
255
+ return ['>', ['get', field], value];
256
+ case '_gte':
257
+ return ['>=', ['get', field], value];
258
+ case '_lt':
259
+ return ['<', ['get', field], value];
260
+ case '_lte':
261
+ return ['<=', ['get', field], value];
262
+ case '_contains':
263
+ return ['in', value, ['get', field]];
264
+ case '_icontains':
265
+ // Case-insensitive contains using downcase for both field value and search value
266
+ return ['in', ['downcase', value], ['downcase', ['get', field]]];
267
+ case '_ncontains':
268
+ return ['!', ['in', value, ['get', field]]];
269
+ case '_starts_with':
270
+ return ['==', ['slice', ['get', field], 0, value.length], value];
271
+ case '_istarts_with':
272
+ // Case-insensitive starts with using downcase
273
+ return ['==', ['slice', ['downcase', ['get', field]], 0, value.length], ['downcase', value]];
274
+ case '_nstarts_with':
275
+ return ['!=', ['slice', ['get', field], 0, value.length], value];
276
+ case '_ends_with':
277
+ return ['==', ['slice', ['get', field], ['-', ['length', ['get', field]], value.length]], value];
278
+ case '_iends_with':
279
+ // Case-insensitive ends with using downcase
280
+ return ['==', ['slice', ['downcase', ['get', field]], ['-', ['length', ['get', field]], value.length]], ['downcase', value]];
281
+ case '_nends_with':
282
+ return ['!=', ['slice', ['get', field], ['-', ['length', ['get', field]], value.length]], value];
283
+ case '_null':
284
+ return ['==', ['get', field], null];
285
+ case '_nnull':
286
+ return ['!=', ['get', field], null];
287
+ default:
288
+ console.warn(`Unsupported search operator for MapLibre filter: ${operator}`);
289
+ return ['==', ['get', field], value];
290
+ }
291
+ });
292
+
293
+ // Combine filters with the specified connector
294
+ if (filters.length === 1) {
295
+ return filters[0];
296
+ }
297
+
298
+ if (query.connector === '_and') {
299
+ return ['all', ...filters];
300
+ } else {
301
+ return ['any', ...filters];
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Converts a search query to a client-side predicate function
307
+ * Fallback when MapLibre filtering is not suitable or for complex operations
308
+ */
309
+ export function searchQueryToPredicate(query: import('./types').SearchQuery): (item: Feature | DataRow) => boolean {
310
+ if (!query.filters || query.filters.length === 0) {
311
+ return () => true; // No filter, match all
312
+ }
313
+
314
+ return (item: Feature | DataRow) => {
315
+ const properties = 'properties' in item ? item.properties : item;
316
+
317
+ const results = query.filters.map(filter => {
318
+ const { field, operator, value } = filter;
319
+ const fieldValue = properties?.[field];
320
+
321
+ switch (operator) {
322
+ case '_eq':
323
+ return fieldValue == value;
324
+ case '_neq':
325
+ return fieldValue != value;
326
+ case '_gt':
327
+ return Number(fieldValue) > Number(value);
328
+ case '_gte':
329
+ return Number(fieldValue) >= Number(value);
330
+ case '_lt':
331
+ return Number(fieldValue) < Number(value);
332
+ case '_lte':
333
+ return Number(fieldValue) <= Number(value);
334
+ case '_contains':
335
+ return typeof fieldValue === 'string' && fieldValue.includes(String(value));
336
+ case '_icontains':
337
+ return typeof fieldValue === 'string' && fieldValue.toLowerCase().includes(String(value).toLowerCase());
338
+ case '_ncontains':
339
+ return typeof fieldValue === 'string' && !fieldValue.includes(String(value));
340
+ case '_starts_with':
341
+ return typeof fieldValue === 'string' && fieldValue.startsWith(String(value));
342
+ case '_istarts_with':
343
+ return typeof fieldValue === 'string' && fieldValue.toLowerCase().startsWith(String(value).toLowerCase());
344
+ case '_nstarts_with':
345
+ return typeof fieldValue === 'string' && !fieldValue.startsWith(String(value));
346
+ case '_ends_with':
347
+ return typeof fieldValue === 'string' && fieldValue.endsWith(String(value));
348
+ case '_iends_with':
349
+ return typeof fieldValue === 'string' && fieldValue.toLowerCase().endsWith(String(value).toLowerCase());
350
+ case '_nends_with':
351
+ return typeof fieldValue === 'string' && !fieldValue.endsWith(String(value));
352
+ case '_null':
353
+ return fieldValue == null;
354
+ case '_nnull':
355
+ return fieldValue != null;
356
+ default:
357
+ console.warn(`Unsupported search operator: ${operator}`);
358
+ return true;
359
+ }
360
+ });
361
+
362
+ // Apply connector logic
363
+ return query.connector === '_and'
364
+ ? results.every(Boolean)
365
+ : results.some(Boolean);
366
+ };
367
+ }
@@ -0,0 +1,62 @@
1
+ # Core Components
2
+
3
+ This directory contains reusable, framework-level components that provide essential functionality for the s:CMS system.
4
+
5
+ ## Components
6
+
7
+ ### SEO.astro
8
+
9
+ Comprehensive SEO component that generates meta tags and structured data for search engines and social media.
10
+
11
+ **Usage:**
12
+
13
+ ```astro
14
+ ---
15
+ import SEO from '@lad-sapienza/scms-core/components/SEO/SEO.astro';
16
+ ---
17
+
18
+ <head>
19
+ <SEO
20
+ title="Page Title"
21
+ description="Page description"
22
+ image="/images/og-image.jpg"
23
+ author="Author Name"
24
+ isArticle={true}
25
+ datePublished="2026-01-14"
26
+ tags={['tag1', 'tag2']}
27
+ />
28
+ </head>
29
+ ```
30
+
31
+ **Props:**
32
+ - `title` (string, optional) - Page title
33
+ - `description` (string, optional) - Page description
34
+ - `image` (string, optional) - Social media image URL
35
+ - `author` (string, optional) - Content author name
36
+ - `isArticle` (boolean, optional) - Whether this is an article (adds Article schema)
37
+ - `datePublished` (string|Date, optional) - Publication date
38
+ - `dateModified` (string|Date, optional) - Last modification date
39
+ - `tags` (string[], optional) - Article tags
40
+ - `canonical` (string, optional) - Canonical URL override
41
+
42
+ **Features:**
43
+ - Open Graph meta tags for Facebook/LinkedIn
44
+ - Twitter Card meta tags
45
+ - JSON-LD structured data for articles
46
+ - Automatic canonical URL generation
47
+ - Image URL resolution
48
+
49
+ ## Extending Core
50
+
51
+ Core components should be:
52
+ 1. **Framework-level**: Provide essential, reusable functionality
53
+ 2. **Well-documented**: Include usage examples and prop descriptions
54
+ 3. **Tested**: Ensure they work across different use cases
55
+ 4. **Minimal dependencies**: Keep external dependencies to a minimum
56
+
57
+ ## User Customization
58
+
59
+ Users should **not** edit core components directly. Instead:
60
+ - Override behavior in `usr/layouts/`
61
+ - Create custom components in `usr/components/`
62
+ - Extend layouts with slots and composition
@@ -0,0 +1,35 @@
1
+ import React, { useContext } from 'react';
2
+ import { RecordContext } from './context';
3
+ import { getValueByDotPath } from './utils';
4
+
5
+ export interface FieldProps {
6
+ name: string;
7
+ transformer?: (value: any) => React.ReactNode;
8
+ fallback?: React.ReactNode;
9
+ record?: any;
10
+ }
11
+
12
+ export function Field({ name, transformer, fallback = null, record }: FieldProps) {
13
+ const contextRecord = useContext(RecordContext);
14
+ const sourceRecord = record ?? contextRecord;
15
+
16
+ if (!sourceRecord) return <>{fallback}</>;
17
+
18
+ const value = getValueByDotPath(sourceRecord, name);
19
+
20
+ if (value === undefined || value === null || value === '') {
21
+ return <>{fallback}</>;
22
+ }
23
+
24
+ if (transformer) {
25
+ return <>{transformer(value)}</>;
26
+ }
27
+
28
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
29
+ return <>{String(value)}</>;
30
+ }
31
+
32
+ return <>{JSON.stringify(value, null, 2)}</>;
33
+ }
34
+
35
+ export default Field;