@reldens/cms 0.48.0 → 0.50.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.
@@ -96,3 +96,278 @@ cms.events.on('adminEntityExtraData', ({entitySerializedData, entity}) => {
96
96
  entitySerializedData.customField = 'Custom Value';
97
97
  });
98
98
  ```
99
+
100
+ ## Event-Driven Customizations
101
+
102
+ The CMS uses an event system for extensibility. This allows you to add custom behavior without modifying core CMS files.
103
+
104
+ ### Admin UI Customizations
105
+
106
+ Add buttons and content to admin views using events. Always use templates and renderCallback - never hardcode HTML in classes.
107
+
108
+ **Step 1: Create template file** (`admin/templates/custom-button.html`):
109
+ ```html
110
+ <button class="button button-primary" type="submit" form="edit-form"
111
+ name="customAction" value="customValue">
112
+ {{&buttonText}}
113
+ </button>
114
+ ```
115
+
116
+ **Step 2: Register template** in `lib/templates-list.js`:
117
+ ```javascript
118
+ module.exports.TemplatesList = {
119
+ // ... existing templates
120
+ customButton: 'custom-button.html',
121
+ };
122
+ ```
123
+
124
+ **Step 3: Create subscriber class**:
125
+ ```javascript
126
+ const { Logger, sc } = require('@reldens/utils');
127
+
128
+ class CustomButtonSubscriber
129
+ {
130
+
131
+ constructor(props)
132
+ {
133
+ this.events = sc.get(props, 'events', false);
134
+ this.renderCallback = sc.get(props, 'renderCallback', false);
135
+ this.customButtonTemplate = sc.get(props, 'customButtonTemplate', '');
136
+ this.translations = sc.get(props, 'translations', {});
137
+ this.setupEvents();
138
+ }
139
+
140
+ setupEvents()
141
+ {
142
+ if(!this.events){
143
+ Logger.error('Events Manager not found on CustomButtonSubscriber.');
144
+ return false;
145
+ }
146
+ this.events.on('reldens.adminEditPropertiesPopulation', this.addCustomButton.bind(this));
147
+ }
148
+
149
+ async addCustomButton(event)
150
+ {
151
+ if(!this.customButtonTemplate){
152
+ Logger.error('Custom button template not found');
153
+ return '';
154
+ }
155
+ if(!this.renderCallback){
156
+ Logger.error('Render callback not available');
157
+ return '';
158
+ }
159
+ if(!event.renderedEditProperties.extraContentForEdit){
160
+ event.renderedEditProperties.extraContentForEdit = '';
161
+ }
162
+ let buttonHtml = await this.renderCallback(
163
+ this.customButtonTemplate,
164
+ {
165
+ buttonText: sc.get(this.translations.labels, 'customAction', 'Custom Action')
166
+ }
167
+ );
168
+ event.renderedEditProperties.extraContentForEdit += buttonHtml;
169
+ }
170
+
171
+ }
172
+ ```
173
+
174
+ **Step 4: Initialize in AdminManager**:
175
+ ```javascript
176
+ this.customButtonSubscriber = new CustomButtonSubscriber({
177
+ events: this.events,
178
+ renderCallback: this.renderCallback,
179
+ customButtonTemplate: this.adminFilesContents.customButton,
180
+ translations: this.translations
181
+ });
182
+ ```
183
+
184
+ **See `lib/cache/save-and-clear-cache-subscriber.js` for complete working example.**
185
+
186
+ ### Form Processing Customizations
187
+
188
+ Handle custom save actions and form submissions:
189
+
190
+ ```javascript
191
+ class CustomFormHandler
192
+ {
193
+
194
+ constructor(props)
195
+ {
196
+ this.events = props.events;
197
+ this.dataServer = props.dataServer;
198
+ this.setupEvents();
199
+ }
200
+
201
+ setupEvents()
202
+ {
203
+ this.events.on('reldens.dynamicFormRequestHandler.beforeSave', this.beforeSave.bind(this));
204
+ this.events.on('reldens.dynamicFormRequestHandler.afterSave', this.afterSave.bind(this));
205
+ }
206
+
207
+ async beforeSave(event)
208
+ {
209
+ if(event.validationResult.errors){
210
+ event.validationResult.extraErrors = this.customValidation(event.formData);
211
+ }
212
+ }
213
+
214
+ async afterSave(event)
215
+ {
216
+ let customAction = event.req?.body?.customAction;
217
+ if('customValue' === customAction){
218
+ await this.performCustomAction(event.result);
219
+ }
220
+ }
221
+
222
+ customValidation(formData)
223
+ {
224
+ let errors = [];
225
+ if(formData.customField && formData.customField.length < 10){
226
+ errors.push('customField must be at least 10 characters');
227
+ }
228
+ return errors;
229
+ }
230
+
231
+ async performCustomAction(savedEntity)
232
+ {
233
+ Logger.info('Performing custom action for entity:', savedEntity.id);
234
+ }
235
+
236
+ }
237
+
238
+ let manager = new Manager(managerConfiguration);
239
+ new CustomFormHandler({
240
+ events: manager.events,
241
+ dataServer: manager.dataServer
242
+ });
243
+ ```
244
+
245
+ ### Entity Config Overrides
246
+
247
+ Customize generated entity configurations without editing generated files:
248
+
249
+ ```javascript
250
+ class CmsPagesOverride
251
+ {
252
+
253
+ /**
254
+ * @param {Object} baseConfig
255
+ * @param {Object} props
256
+ * @returns {Object}
257
+ */
258
+ static propertiesConfig(baseConfig, props)
259
+ {
260
+ baseConfig.properties.meta_og_image = {
261
+ ...baseConfig.properties.meta_og_image,
262
+ isUpload: true,
263
+ allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
264
+ bucket: 'cms-og-images',
265
+ bucketPath: '/og-images/'
266
+ };
267
+ baseConfig.properties.status = {
268
+ ...baseConfig.properties.status,
269
+ type: 'select',
270
+ options: [
271
+ { value: 'draft', label: 'Draft' },
272
+ { value: 'published', label: 'Published' },
273
+ { value: 'archived', label: 'Archived' }
274
+ ]
275
+ };
276
+ return baseConfig;
277
+ }
278
+
279
+ }
280
+
281
+ let manager = new Manager({
282
+ ...managerConfiguration,
283
+ entitiesConfigOverride: {
284
+ 'cmsPages': CmsPagesOverride // CRITICAL: Use camelCase key from entities-config.js
285
+ }
286
+ });
287
+ ```
288
+
289
+ **CRITICAL: Entity keys must match exactly as defined in `generated-entities/entities-config.js`:**
290
+ - Use `'cmsPages'` NOT `'cms-pages'`
291
+ - Use `'cmsBlocks'` NOT `'cms-blocks'`
292
+ - Use `'cmsForms'` NOT `'cms-forms'`
293
+ - Keys are camelCase, not kebab-case
294
+
295
+ ### Conditional Event Handling
296
+
297
+ Control when customizations apply based on conditions:
298
+
299
+ ```javascript
300
+ const { Logger, sc } = require('@reldens/utils');
301
+
302
+ class ConditionalCustomization
303
+ {
304
+
305
+ constructor(props)
306
+ {
307
+ this.events = sc.get(props, 'events', false);
308
+ this.cacheManager = sc.get(props, 'cacheManager', false);
309
+ this.renderCallback = sc.get(props, 'renderCallback', false);
310
+ this.contentTemplate = sc.get(props, 'contentTemplate', '');
311
+ this.setupEvents();
312
+ }
313
+
314
+ setupEvents()
315
+ {
316
+ if(!this.cacheManager?.isEnabled()){
317
+ return false;
318
+ }
319
+ if(!this.events){
320
+ Logger.error('Events Manager not found.');
321
+ return false;
322
+ }
323
+ this.events.on('reldens.adminEditPropertiesPopulation', this.addContent.bind(this));
324
+ }
325
+
326
+ async addContent(event)
327
+ {
328
+ if('routes' !== event.driverResource.id()){
329
+ return false;
330
+ }
331
+ if(!this.contentTemplate || !this.renderCallback){
332
+ return false;
333
+ }
334
+ if(!event.renderedEditProperties.extraContentForEdit){
335
+ event.renderedEditProperties.extraContentForEdit = '';
336
+ }
337
+ let contentHtml = await this.renderCallback(this.contentTemplate, {});
338
+ event.renderedEditProperties.extraContentForEdit += contentHtml;
339
+ }
340
+
341
+ }
342
+
343
+ let manager = new Manager(managerConfiguration);
344
+ new ConditionalCustomization({
345
+ events: manager.events,
346
+ cacheManager: manager.cacheManager,
347
+ renderCallback: manager.renderCallback,
348
+ contentTemplate: manager.adminFilesContents.conditionalContent
349
+ });
350
+ ```
351
+
352
+ **CRITICAL: Never hardcode HTML strings in subscriber classes. Always use templates with renderCallback and translations.**
353
+
354
+ ### Available Admin Events
355
+
356
+ **View/Edit/List Property Population**:
357
+ - `reldens.adminViewPropertiesPopulation` - Add content to view pages
358
+ - `reldens.adminEditPropertiesPopulation` - Add content to edit pages
359
+ - `reldens.adminListPropertiesPopulation` - Add content to list pages
360
+
361
+ **Entity Operations**:
362
+ - `reldens.adminBeforeEntitySave` - Before entity save
363
+ - `reldens.adminAfterEntitySave` - After entity save
364
+
365
+ **Form Processing**:
366
+ - `reldens.dynamicFormRequestHandler.beforeValidation` - Before form validation
367
+ - `reldens.dynamicFormRequestHandler.beforeSave` - Before form save
368
+ - `reldens.dynamicFormRequestHandler.afterSave` - After form save
369
+
370
+ **Setup Events**:
371
+ - `reldens.setupAdminRouter` - Setup admin router
372
+ - `reldens.setupAdminRoutes` - Setup admin routes
373
+ - `reldens.setupAdminManagers` - Setup admin managers
@@ -30,7 +30,13 @@ Every template has access to system variables providing context about the curren
30
30
 
31
31
  ## Template Functions
32
32
 
33
- ### URL Generation
33
+ ### URL Transformers
34
+
35
+ The CMS provides three URL transformers with different behaviors for CDN support:
36
+
37
+ #### [url()] - Public URL (No CDN)
38
+
39
+ Generates URLs using the **public base URL** without CDN support.
34
40
 
35
41
  ```html
36
42
  [url(/articles)]
@@ -38,12 +44,84 @@ Every template has access to system variables providing context about the curren
38
44
  [url(/css/styles.css)]
39
45
  ```
40
46
 
41
- ### Asset URLs
47
+ **Output:**
48
+ - Without CDN: `https://example.com/css/styles.css`
49
+ - With CDN configured: `https://example.com/css/styles.css` (same, CDN ignored)
50
+
51
+ **Use for:** Internal application routes, API endpoints, form actions.
52
+
53
+ #### [cdn()] - CDN or Public URL
54
+
55
+ Generates URLs using the **CDN URL if configured**, otherwise falls back to public URL.
42
56
 
43
57
  ```html
44
- [asset(/assets/images/logo.png)]
58
+ [cdn(/css/styles.css)]
59
+ [cdn(/js/scripts.js)]
60
+ [cdn(/assets/web/logo.png)]
45
61
  ```
46
62
 
63
+ **Output:**
64
+ - Without CDN: `https://example.com/css/styles.css`
65
+ - With CDN configured: `https://cdn.example.com/css/styles.css`
66
+
67
+ **Use for:** Static assets outside `/assets` folder (CSS, JS, fonts, etc.).
68
+
69
+ #### [asset()] - CDN or Public URL + /assets Prefix
70
+
71
+ Generates URLs using the **CDN URL if configured** and **automatically prepends `/assets`** to the path.
72
+
73
+ ```html
74
+ [asset(/web/logo.png)]
75
+ [asset(/images/hero.jpg)]
76
+ ```
77
+
78
+ **Output:**
79
+ - Without CDN: `https://example.com/assets/web/logo.png`
80
+ - With CDN configured: `https://cdn.example.com/assets/web/logo.png`
81
+
82
+ **Use for:** Static assets inside `/assets` folder (images, downloads, media files).
83
+
84
+ ---
85
+
86
+ ### URL Transformer Best Practices
87
+
88
+ **Based on folder structure:**
89
+
90
+ ```
91
+ public/
92
+ assets/ # Use [asset(/file)] OR [cdn(/assets/file)]
93
+ web/
94
+ images/
95
+ downloads/
96
+ css/ # Use [cdn(/css/file)]
97
+ js/ # Use [cdn(/js/file)]
98
+ fonts/ # Use [cdn(/fonts/file)]
99
+ ```
100
+
101
+ **Examples:**
102
+
103
+ ```html
104
+ <!-- Files in public/assets/ -->
105
+ <meta property="og:image" content="[asset(/web/logo.png)]"/>
106
+ <img src="[asset(/images/hero.jpg)]" alt="Hero"/>
107
+
108
+ <!-- Files in public/css/, public/js/, etc. -->
109
+ <link rel="stylesheet" href="[cdn(/css/styles.css)]"/>
110
+ <script src="[cdn(/js/scripts.js)]"></script>
111
+
112
+ <!-- Internal routes -->
113
+ <form action="[url(/api/submit)]" method="post">
114
+ <a href="[url(/articles)]">Articles</a>
115
+ ```
116
+
117
+ **Key Rules:**
118
+ - `[asset(/file)]` adds `/assets` prefix automatically → Use for files in `/assets` folder
119
+ - `[cdn(/assets/file)]` requires full path → Alternative for files in `/assets` folder
120
+ - `[cdn(/css/file)]` requires full path → Use for files outside `/assets` (css, js, fonts)
121
+ - `[url(/path)]` never uses CDN → Use for application routes only
122
+
123
+ **Recommendation:** Use `[asset()]` for `/assets` files (cleaner syntax), `[cdn()]` for everything else.
124
+
47
125
  ### Date Formatting
48
126
 
49
127
  ```html
package/CLAUDE.md CHANGED
@@ -407,13 +407,21 @@ templates/
407
407
 
408
408
  **Template Functions:**
409
409
  ```html
410
- [url(/articles)] <!-- URL generation -->
411
- [asset(/img/logo.png)] <!-- Asset URLs -->
410
+ [url(/articles)] <!-- URL generation (no CDN) -->
411
+ [cdn(/css/styles.css)] <!-- CDN URL (or public URL if no CDN) -->
412
+ [asset(/web/logo.png)] <!-- Asset URL with /assets prefix (uses CDN if available) -->
412
413
  [date(now, Y-m-d)] <!-- Date formatting -->
413
414
  [translate(welcome.message)] <!-- i18n -->
414
415
  [t(key, Default, {var: value})] <!-- i18n with interpolation -->
415
416
  ```
416
417
 
418
+ **URL Transformer Selection:**
419
+ - `[url()]` - Application routes (never uses CDN)
420
+ - `[cdn()]` - Static assets outside `/assets` (CSS, JS, fonts)
421
+ - `[asset()]` - Static assets inside `/assets` folder (images, media)
422
+
423
+ See `.claude/templating-system-guide.md` for detailed URL transformer documentation.
424
+
417
425
  ## Configuration
418
426
 
419
427
  ### Environment Variables (.env)
@@ -513,10 +521,16 @@ The CMS provides extensive event hooks for customization:
513
521
  - `reldens.setupAdminRoutes` - After route setup
514
522
  - `reldens.setupAdminManagers` - After manager setup
515
523
  - `reldens.adminBeforeEntitySave` - Before entity save (used by password encryption handler)
524
+ - `reldens.adminAfterEntitySave` - After entity save
525
+ - `reldens.adminViewPropertiesPopulation` - Add content to view pages
526
+ - `reldens.adminEditPropertiesPopulation` - Add content to edit pages
527
+ - `reldens.adminListPropertiesPopulation` - Add content to list pages
516
528
 
517
529
  ### Template Reloading Events
518
530
  - `reldens.templateReloader.templatesChanged` - Templates changed
519
531
 
532
+ See `.claude/advanced-usage-guide.md` for comprehensive event-driven customization patterns and examples.
533
+
520
534
  ## Development Workflow
521
535
 
522
536
  ### Template Reloading
@@ -561,6 +575,67 @@ Define custom entity configurations in `entitiesConfig`:
561
575
  }
562
576
  ```
563
577
 
578
+ ### Entity Config Overrides
579
+ Override generated entity configurations without editing generated files using `entitiesConfigOverride`:
580
+ ```javascript
581
+ class CmsPagesOverride
582
+ {
583
+
584
+ /**
585
+ * @param {Object} baseConfig
586
+ * @param {Object} props
587
+ * @returns {Object}
588
+ */
589
+ static propertiesConfig(baseConfig, props)
590
+ {
591
+ baseConfig.properties.meta_og_image = {
592
+ dbType: 'varchar',
593
+ isUpload: true,
594
+ allowedTypes: 'image',
595
+ bucket: 'public/assets/media',
596
+ bucketPath: '/assets/media/'
597
+ };
598
+ return baseConfig;
599
+ }
600
+
601
+ }
602
+
603
+ const manager = new Manager({
604
+ entitiesConfigOverride: {
605
+ 'cmsPages': CmsPagesOverride // Use camelCase key from entities-config.js
606
+ }
607
+ });
608
+ ```
609
+
610
+ **CRITICAL: Entity keys must match exactly as defined in `generated-entities/entities-config.js`**
611
+ - Use camelCase: `'cmsPages'`, `'cmsBlocks'`, `'cmsForms'`
612
+ - NOT kebab-case: `'cms-pages'`, `'cms-blocks'`, `'cms-forms'`
613
+
614
+ Supports three override patterns:
615
+ - **Class-based**: Class with static `propertiesConfig(baseConfig, props)` method
616
+ - **Function-based**: Function that receives `(baseConfig, props)` and returns modified config
617
+ - **Object-based**: Plain object that gets deep merged with base config
618
+
619
+ **Upload Field Configuration:**
620
+ - `allowedTypes`: Must be a STRING ('image', 'audio', 'text'), NOT an array
621
+ - `bucket`: Relative path from projectRoot (e.g., 'public/assets/media')
622
+ - `bucketPath`: URL path for display (e.g., '/assets/media/')
623
+ - Do NOT use spread syntax - replace entire property object
624
+ - Do NOT use FileHandler.joinPaths() - bucket paths are relative to projectRoot
625
+
626
+ **Upload Field Removal:**
627
+ - Admin edit pages automatically show an "X" button before uploaded filenames
628
+ - Clicking X creates hidden input `clear_fieldname=1` in form
629
+ - Server detects this and sets field to null in database
630
+ - File remains on disk - only database field is cleared
631
+ - Template: `admin/templates/fields/edit/file-claude.html`
632
+ - Client JS: `admin/reldens-admin-client-claude.js` (remove upload functionality)
633
+ - Server handling: `lib/admin-manager/router-contents-claude.js` (clear_ parameter detection)
634
+
635
+ **Example subscribers**: See `lib/cache/add-cache-button-subscriber.js` and `lib/cache/save-and-clear-cache-subscriber.js` for complete working examples of event-driven UI customizations.
636
+
637
+ See `.claude/advanced-usage-guide.md` for detailed examples.
638
+
564
639
  ## Security Features
565
640
 
566
641
  - **Authentication** - Role-based admin access
@@ -25,7 +25,7 @@
25
25
  background-color: var(--lightGrey);
26
26
  margin: 0;
27
27
  padding: 0;
28
- font-size: 12px;
28
+ font-size: 0.75rem;
29
29
 
30
30
  .wrapper {
31
31
  display: flex;
@@ -40,7 +40,7 @@
40
40
  right: 0;
41
41
  padding: 1rem 5rem 1rem 2rem;
42
42
  border-radius: 8px 0 0 8px;
43
- font-size: 14px;
43
+ font-size: 0.875rem;
44
44
 
45
45
  &.success, &.error {
46
46
  display: block;
@@ -91,7 +91,7 @@
91
91
  padding: 0.5rem 1rem;
92
92
  border: none;
93
93
  border-radius: 4px;
94
- font-size: 14px;
94
+ font-size: 0.875rem;
95
95
  cursor: pointer;
96
96
  text-decoration: none;
97
97
 
@@ -211,7 +211,7 @@
211
211
  color: var(--lightGrey);
212
212
  text-decoration: none;
213
213
  border-left: 3px solid transparent;
214
- font-size: 12px;
214
+ font-size: 0.75rem;
215
215
  border-left: 3px solid #0000;
216
216
  padding: 0.1rem 0.1rem 0.1rem 1rem;
217
217
  margin-top: 0.3rem;
@@ -242,7 +242,7 @@
242
242
  color: var(--lightGrey);
243
243
  text-decoration: none;
244
244
  cursor: pointer;
245
- font-size: 14px;
245
+ font-size: 0.875rem;
246
246
  font-weight: var(--font-semi-bold);
247
247
  border-bottom: 1px solid var(--darkBlue);
248
248
 
@@ -274,6 +274,10 @@
274
274
  justify-content: end;
275
275
  margin-bottom: 1rem;
276
276
  }
277
+
278
+ .extra-actions {
279
+ justify-content: end;
280
+ }
277
281
  }
278
282
 
279
283
  .forms-container {
@@ -283,7 +287,7 @@
283
287
  }
284
288
 
285
289
  .form-title {
286
- font-size: 22px;
290
+ font-size: 1.375rem;
287
291
  margin-bottom: 2%;
288
292
  color: var(--darkGrey);
289
293
  text-align: center;
@@ -329,7 +333,7 @@
329
333
  }
330
334
 
331
335
  & h2 {
332
- font-size: 22px;
336
+ font-size: 1.375rem;
333
337
  margin: 0 0 2rem;
334
338
  color: var(--darkGrey);
335
339
  text-align: center;
@@ -533,7 +537,7 @@
533
537
  vertical-align: middle;
534
538
  align-items: center;
535
539
  margin: 0 1rem 1rem 0;
536
- font-size: 14px;
540
+ font-size: 0.875rem;
537
541
  color: var(--darkGrey);
538
542
 
539
543
  &.filters-toggle {
@@ -650,7 +654,7 @@
650
654
 
651
655
  .entity-view, .entity-edit {
652
656
  & h2 {
653
- font-size: 22px;
657
+ font-size: 1.375rem;
654
658
  margin-bottom: 2rem;
655
659
  color: var(--darkGrey);
656
660
  text-align: center;
@@ -691,6 +695,10 @@
691
695
  margin-left: 0.5rem;
692
696
  margin-top: 0.5rem;
693
697
  }
698
+
699
+ .remove-upload-btn {
700
+ cursor: pointer;
701
+ }
694
702
  }
695
703
  }
696
704
  }
@@ -757,7 +765,7 @@
757
765
  .extra-actions {
758
766
  display: flex;
759
767
  width: 100%;
760
- justify-content: end;
768
+ justify-content: center;
761
769
  margin: 1rem 0;
762
770
  }
763
771
 
@@ -804,7 +812,7 @@
804
812
 
805
813
  .dialog-content h5 {
806
814
  margin: 0 0 1rem 0;
807
- font-size: 18px;
815
+ font-size: 1.125rem;
808
816
  color: var(--darkGrey);
809
817
  }
810
818
 
@@ -242,6 +242,9 @@ window.addEventListener('DOMContentLoaded', () => {
242
242
  ? 'Success!'
243
243
  : 'There was an error: '+escapeHTML(errorMessages[result] || result);
244
244
  deleteCookie('result');
245
+ queryParams.delete('result');
246
+ let newUrl = location.pathname + (queryParams.toString() ? '?' + queryParams.toString() : '');
247
+ window.history.replaceState({}, '', newUrl);
245
248
  }
246
249
  }
247
250
 
@@ -282,4 +285,35 @@ window.addEventListener('DOMContentLoaded', () => {
282
285
  });
283
286
  }
284
287
 
288
+ // remove upload button functionality:
289
+ let removeUploadButtons = document.querySelectorAll('.remove-upload-btn');
290
+ if(removeUploadButtons){
291
+ for(let button of removeUploadButtons){
292
+ button.addEventListener('click', (event) => {
293
+ event.preventDefault();
294
+ let fieldName = button.getAttribute('data-field');
295
+ let fileInput = document.getElementById(fieldName);
296
+ let currentFileDisplay = document.querySelector('.upload-current-file[data-field="'+fieldName+'"]');
297
+ if(currentFileDisplay){
298
+ currentFileDisplay.style.display = 'none';
299
+ }
300
+ if(fileInput){
301
+ fileInput.value = '';
302
+ let form = fileInput.closest('form');
303
+ if(form){
304
+ let clearFieldName = 'clear_'+fieldName;
305
+ let existingClearInput = form.querySelector('input[name="'+clearFieldName+'"]');
306
+ if(!existingClearInput){
307
+ let clearInput = document.createElement('input');
308
+ clearInput.type = 'hidden';
309
+ clearInput.name = clearFieldName;
310
+ clearInput.value = '1';
311
+ form.appendChild(clearInput);
312
+ }
313
+ }
314
+ }
315
+ });
316
+ }
317
+ }
318
+
285
319
  });
@@ -1,4 +1,5 @@
1
1
  <form class="cache-clean-form" method="POST" action="{{&cacheCleanRoute}}">
2
2
  <input type="hidden" name="routeId" value="{{routeId}}"/>
3
+ <input type="hidden" name="refererUrl" value="{{&refererUrl}}"/>
3
4
  <button class="button button-warning cache-clean-btn" type="submit">{{&buttonText}}</button>
4
5
  </form>
@@ -6,6 +6,9 @@
6
6
  <button class="button button-primary button-submit" type="submit" form="edit-form" name="saveAction" value="saveAndGoBack">Save and go back</button>
7
7
  <a class="button button-secondary button-back" href="{{&entityViewRoute}}">Cancel</a>
8
8
  </div>
9
+ <div class="extra-actions">
10
+ {{&extraContent}}
11
+ </div>
9
12
  <form name="edit-form" id="edit-form" action="{{&entitySaveRoute}}" method="post"{{&multipartFormData}}>
10
13
  <input type="hidden" name="{{&idProperty}}" value="{{&idValue}}"/>
11
14
  <div class="edit-field">
@@ -1,2 +1,2 @@
1
- <p>{{&fieldValue}}</p>
1
+ {{#fieldValue}}<p class="upload-current-file" data-field="{{&fieldName}}"><button type="button" class="remove-upload-btn" data-field="{{&fieldName}}" title="REMOVE">X</button> - {{&fieldValue}}</p>{{/fieldValue}}
2
2
  <input type="file" name="{{&fieldName}}" id="{{&fieldName}}"{{&required}}{{&fieldDisabled}}{{&multiple}}/>
@@ -4,13 +4,16 @@
4
4
  <a class="button button-primary" href="{{&entityNewRoute}}">Create New</a>
5
5
  <a class="button button-primary" href="{{&entityEditRoute}}">Edit</a>
6
6
  <a class="button button-secondary" href="{{&entityListRoute}}">Back</a>
7
- <form class="form-delete" name="delete-form-top-{{&id}}" id="delete-form-top-{{&id}}" action="{{&entityDeleteRoute}}" method="post">
7
+ <form class="form-delete" name="delete-form-top-{{&id}}" action="{{&entityDeleteRoute}}" method="post">
8
8
  <span>
9
9
  <input type="hidden" name="ids[]" value="{{&id}}"/>
10
10
  <button class="button button-danger" type="submit">Delete</button>
11
11
  </span>
12
12
  </form>
13
13
  </div>
14
+ <div class="extra-actions">
15
+ {{&extraContent}}
16
+ </div>
14
17
  {{#fields}}
15
18
  <div class="view-field">
16
19
  <span class="field-name">{{&name}}</span>
@@ -22,7 +25,7 @@
22
25
  <a class="button button-primary" href="{{&entityNewRoute}}">Create New</a>
23
26
  <a class="button button-primary" href="{{&entityEditRoute}}">Edit</a>
24
27
  <a class="button button-secondary" href="{{&entityListRoute}}">Back</a>
25
- <form class="form-delete" name="delete-form-{{&id}}" id="delete-form-{{&id}}" action="{{&entityDeleteRoute}}" method="post">
28
+ <form class="form-delete" name="delete-form-{{&id}}" action="{{&entityDeleteRoute}}" method="post">
26
29
  <span>
27
30
  <input type="hidden" name="ids[]" value="{{&id}}"/>
28
31
  <button class="button button-danger" type="submit">Delete</button>
@@ -473,6 +473,8 @@ class RouterContents
473
473
  if(!shouldProcess){
474
474
  continue;
475
475
  }
476
+ let clearFieldParam = sc.get(req.body, 'clear_'+i, false);
477
+ let isExplicitClear = '1' === clearFieldParam;
476
478
  let propertyUpdateValue = sc.get(req.body, i, null);
477
479
  if('null' === propertyUpdateValue){
478
480
  propertyUpdateValue = null;
@@ -480,7 +482,12 @@ class RouterContents
480
482
  let propertyType = sc.get(property, 'type', 'string');
481
483
  if(property.isUpload){
482
484
  propertyType = 'upload';
483
- propertyUpdateValue = this.prepareUploadPatchData(req, i, propertyUpdateValue, property);
485
+ if(isExplicitClear){
486
+ propertyUpdateValue = null;
487
+ }
488
+ if(!isExplicitClear){
489
+ propertyUpdateValue = this.prepareUploadPatchData(req, i, propertyUpdateValue, property);
490
+ }
484
491
  }
485
492
  if('boolean' === propertyType){
486
493
  propertyUpdateValue = '1' === propertyUpdateValue || 'on' === propertyUpdateValue;
@@ -518,7 +525,7 @@ class RouterContents
518
525
  continue;
519
526
  }
520
527
  }
521
- if(!property.isUpload || (property.isUpload && !isNull)){
528
+ if(!property.isUpload || (property.isUpload && (!isNull || isExplicitClear))){
522
529
  entityDataPatch[i] = propertyUpdateValue;
523
530
  }
524
531
  }
@@ -546,13 +553,13 @@ class RouterContents
546
553
  if(resourceProperty.isArray){
547
554
  fieldValue = fieldValue.split(resourceProperty.isArray).map((value) => {
548
555
  let target = resourceProperty.isUpload ? ' target="_blank"' : '';
549
- let fieldValuePart = resourceProperty.isUpload && resourceProperty.bucketPath
556
+ let fieldValuePart = resourceProperty.isUpload && resourceProperty.bucketPath && value
550
557
  ? resourceProperty.bucketPath+value
551
558
  : value;
552
559
  return {fieldValuePart, fieldOriginalValuePart: value, target};
553
560
  });
554
561
  }
555
- if(!resourceProperty.isArray && resourceProperty.isUpload){
562
+ if(!resourceProperty.isArray && resourceProperty.isUpload && fieldValue){
556
563
  fieldValue = resourceProperty.bucketPath+fieldValue;
557
564
  }
558
565
  }
@@ -87,7 +87,8 @@ class AddCacheButtonSubscriber
87
87
 
88
88
  async generateCacheCleanButton(event)
89
89
  {
90
- if('routes' !== event.driverResource.id()){
90
+ let entityId = event.driverResource.id();
91
+ if('routes' !== entityId && 'cms_pages' !== entityId){
91
92
  return false;
92
93
  }
93
94
  if(!event.loadedEntity){
@@ -98,6 +99,13 @@ class AddCacheButtonSubscriber
98
99
  Logger.error('Missing loaded entity ID on AddCacheButtonSubscriber.');
99
100
  return false;
100
101
  }
102
+ let routeId = event.loadedEntity.id;
103
+ if('cms_pages' === entityId){
104
+ if(!event.loadedEntity.route_id){
105
+ return false;
106
+ }
107
+ routeId = event.loadedEntity.route_id;
108
+ }
101
109
  if(!this.cacheCleanButton){
102
110
  Logger.error('Cache clean button template content not found');
103
111
  return '';
@@ -106,11 +114,13 @@ class AddCacheButtonSubscriber
106
114
  Logger.error('Render callback not available for cache button');
107
115
  return '';
108
116
  }
117
+ let refererUrl = sc.get(event.req, 'originalUrl', '');
109
118
  return await this.renderCallback(
110
119
  this.cacheCleanButton,
111
120
  {
112
121
  cacheCleanRoute: this.cacheCleanRoute,
113
- routeId: event.loadedEntity.id,
122
+ routeId: routeId,
123
+ refererUrl: refererUrl,
114
124
  buttonText: sc.get(this.translations.labels, 'cleanCache', 'cleanCache')
115
125
  }
116
126
  );
@@ -135,7 +145,11 @@ class AddCacheButtonSubscriber
135
145
  buttonText: sc.get(this.translations.labels, 'clearAllCache', 'clearAllCache'),
136
146
  clearAllCacheRoute: this.clearAllCacheRoute,
137
147
  confirmTitle: sc.get(this.translations.labels, 'confirmClearCache', 'confirmClearCache'),
138
- confirmMessage: sc.get(this.translations.messages, 'confirmClearCacheMessage', 'confirmClearCacheMessage'),
148
+ confirmMessage: sc.get(
149
+ this.translations.messages,
150
+ 'confirmClearCacheMessage',
151
+ 'confirmClearCacheMessage'
152
+ ),
139
153
  warningText: sc.get(this.translations.labels, 'warning', 'warning'),
140
154
  warningMessage: sc.get(this.translations.messages, 'clearCacheWarning', 'clearCacheWarning'),
141
155
  cancelText: sc.get(this.translations.labels, 'cancel', 'cancel'),
@@ -15,6 +15,21 @@ class CacheManager
15
15
  this.projectRoot = sc.get(props, 'projectRoot', './');
16
16
  this.cacheBasePath = FileHandler.joinPaths(this.projectRoot, '.reldens_cms_cache');
17
17
  this.enabled = sc.get(props, 'enabled', true);
18
+ this.domainMapping = sc.get(props, 'domainMapping', {});
19
+ this.reverseDomainMapping = this.buildReverseDomainMapping();
20
+ }
21
+
22
+ buildReverseDomainMapping()
23
+ {
24
+ let reverse = {};
25
+ for(let domain in this.domainMapping){
26
+ let canonical = this.domainMapping[domain];
27
+ if(!reverse[canonical]){
28
+ reverse[canonical] = [];
29
+ }
30
+ reverse[canonical].push(domain);
31
+ }
32
+ return reverse;
18
33
  }
19
34
 
20
35
  generateCacheKey(domain, path)
@@ -93,22 +108,22 @@ class CacheManager
93
108
 
94
109
  async delete(domain, path)
95
110
  {
96
- let cacheByDomains = this.fetchCachePathsByDomain(domain, path);
97
- for(let cacheInfo of cacheByDomains){
98
- let allCacheFiles = this.findAllCacheFilesForPath(cacheInfo.domain || domain, path);
111
+ let cacheByDomains = this.resolveCacheDomains(domain);
112
+ if(0 === cacheByDomains.length){
113
+ return true;
114
+ }
115
+ for(let domainInfo of cacheByDomains){
116
+ let actualDomain = domainInfo.domain;
117
+ let allCacheFiles = this.findAllCacheFilesForPath(actualDomain, path);
99
118
  if(0 === allCacheFiles.length){
100
- let singleCacheInfo = this.generateCacheKey(cacheInfo.domain || domain, path);
119
+ let singleCacheInfo = this.generateCacheKey(actualDomain, path);
101
120
  if(!FileHandler.exists(singleCacheInfo.fullPath)){
102
- Logger.debug('No cache files found for: '+path);
121
+ //Logger.debug('No cache files found for: '+path+' in domain: '+actualDomain);
103
122
  continue;
104
123
  }
105
124
  allCacheFiles = [singleCacheInfo.fullPath];
106
125
  }
107
126
  for(let cacheFilePath of allCacheFiles){
108
- if(!FileHandler.exists(cacheFilePath)){
109
- Logger.debug('File does not exist: '+cacheFilePath);
110
- continue;
111
- }
112
127
  if(!FileHandler.remove(cacheFilePath)){
113
128
  Logger.error('Failed to delete cache file: '+cacheFilePath);
114
129
  return false;
@@ -119,19 +134,22 @@ class CacheManager
119
134
  return true;
120
135
  }
121
136
 
122
- fetchCachePathsByDomain(domain, path)
137
+ resolveCacheDomains(domain)
123
138
  {
124
139
  let cacheByDomains = [];
125
- if(domain && 'default' !== domain){
126
- cacheByDomains.push({domain: domain});
127
- return cacheByDomains;
128
- }
129
- if(!FileHandler.exists(this.cacheBasePath)){
140
+ if(!domain || 'default' === domain){
141
+ if(!FileHandler.exists(this.cacheBasePath)){
142
+ return cacheByDomains;
143
+ }
144
+ let cachedDomainFolders = FileHandler.readFolder(this.cacheBasePath);
145
+ for(let cachedDomainFolder of cachedDomainFolders) {
146
+ cacheByDomains.push({domain: cachedDomainFolder});
147
+ }
130
148
  return cacheByDomains;
131
149
  }
132
- let cachedDomainFolders = FileHandler.readFolder(this.cacheBasePath);
133
- for(let cachedDomainFolder of cachedDomainFolders) {
134
- cacheByDomains.push({domain: cachedDomainFolder});
150
+ let domainsToDelete = this.reverseDomainMapping[domain] || [domain];
151
+ for(let mappedDomain of domainsToDelete){
152
+ cacheByDomains.push({domain: mappedDomain});
135
153
  }
136
154
  return cacheByDomains;
137
155
  }
@@ -77,7 +77,10 @@ class CacheRoutesHandler
77
77
  if(!cleanResult){
78
78
  return res.json({error: 'Failed to clean cache'});
79
79
  }
80
- return res.redirect(this.rootPath+'/routes/view'+'?id='+routeId+'&result=success');
80
+ let defaultRedirect = this.rootPath+'/routes/view?id='+routeId;
81
+ let refererUrl = sc.get(req.body, 'refererUrl', defaultRedirect);
82
+ let redirectUrl = refererUrl+(refererUrl.includes('?') ? '&' : '?')+'result=success';
83
+ return res.redirect(redirectUrl);
81
84
  }
82
85
 
83
86
  async processClearAllCache(req, res)
@@ -0,0 +1,59 @@
1
+ /**
2
+ *
3
+ * Reldens - EntitiesConfigProcessor
4
+ *
5
+ */
6
+
7
+ const { Logger, sc } = require('@reldens/utils');
8
+
9
+ class EntitiesConfigProcessor
10
+ {
11
+
12
+ /**
13
+ * @param {Object} baseEntitiesConfig
14
+ * @param {Object} overrides
15
+ * @param {Object} props
16
+ * @returns {Object}
17
+ */
18
+ static applyOverrides(baseEntitiesConfig, overrides, props = {})
19
+ {
20
+ if(!overrides || 'object' !== typeof overrides || 0 === Object.keys(overrides).length){
21
+ return baseEntitiesConfig;
22
+ }
23
+ let mergedConfig = sc.deepMergeProperties({}, baseEntitiesConfig);
24
+ for(let entityKey of Object.keys(overrides)){
25
+ let override = overrides[entityKey];
26
+ if(!override){
27
+ continue;
28
+ }
29
+ let baseConfig = mergedConfig[entityKey] || {};
30
+ mergedConfig[entityKey] = this.applyEntityOverride(entityKey, baseConfig, override, props);
31
+ }
32
+ return mergedConfig;
33
+ }
34
+
35
+ /**
36
+ * @param {string} entityKey
37
+ * @param {Object} baseConfig
38
+ * @param {*} override
39
+ * @param {Object} props
40
+ * @returns {Object}
41
+ */
42
+ static applyEntityOverride(entityKey, baseConfig, override, props)
43
+ {
44
+ if('function' === typeof override && sc.hasOwn(override, 'propertiesConfig')){
45
+ return override.propertiesConfig(baseConfig, props);
46
+ }
47
+ if(sc.isFunction(override)){
48
+ return override(baseConfig, props);
49
+ }
50
+ if('object' === typeof override){
51
+ return sc.deepMergeProperties({}, baseConfig, override);
52
+ }
53
+ Logger.warning('Invalid override type for entity: '+entityKey);
54
+ return baseConfig;
55
+ }
56
+
57
+ }
58
+
59
+ module.exports.EntitiesConfigProcessor = EntitiesConfigProcessor;
package/lib/manager.js CHANGED
@@ -13,6 +13,7 @@ const { AllowedExtensions } = require('./allowed-extensions');
13
13
  const { TemplatesToPathMapper } = require('./templates-to-path-mapper');
14
14
  const { AdminEntitiesGenerator } = require('./admin-entities-generator');
15
15
  const { LoadedEntitiesProcessor } = require('./loaded-entities-processor');
16
+ const { EntitiesConfigProcessor } = require('./entities-config-processor');
16
17
  const { AdminManager } = require('./admin-manager');
17
18
  const { CmsPagesRouteManager } = require('./cms-pages-route-manager');
18
19
  const { Installer } = require('./installer');
@@ -41,6 +42,7 @@ class Manager
41
42
  this.rawRegisteredEntities = sc.get(props, 'rawRegisteredEntities', {});
42
43
  this.entitiesTranslations = sc.get(props, 'entitiesTranslations', {});
43
44
  this.entitiesConfig = sc.get(props, 'entitiesConfig', {});
45
+ this.entitiesConfigOverride = sc.get(props, 'entitiesConfigOverride', {});
44
46
  this.processedEntities = sc.get(props, 'processedEntities', {});
45
47
  this.entityAccess = sc.get(props, 'entityAccess', {});
46
48
  this.authenticationMethod = sc.get(props, 'authenticationMethod', 'db-users');
@@ -92,7 +94,11 @@ class Manager
92
94
  this.developmentExternalDomains = sc.get(props, 'developmentExternalDomains', {});
93
95
  this.appServerFactory = new AppServerFactory();
94
96
  this.adminEntitiesGenerator = new AdminEntitiesGenerator();
95
- this.cacheManager = new CacheManager({projectRoot: this.projectRoot, enabled: this.cache});
97
+ this.cacheManager = new CacheManager({
98
+ projectRoot: this.projectRoot,
99
+ enabled: this.cache,
100
+ domainMapping: this.domainMapping
101
+ });
96
102
  this.templateReloader = new TemplateReloader({
97
103
  reloadTime: this.reloadTime,
98
104
  events: this.events,
@@ -439,10 +445,15 @@ class Manager
439
445
  loadProcessedEntities()
440
446
  {
441
447
  if(0 === Object.keys(this.processedEntities).length){
448
+ let mergedConfig = EntitiesConfigProcessor.applyOverrides(
449
+ this.entitiesConfig,
450
+ this.entitiesConfigOverride,
451
+ {projectRoot: this.projectRoot}
452
+ );
442
453
  this.processedEntities = LoadedEntitiesProcessor.process(
443
454
  this.rawRegisteredEntities,
444
455
  this.entitiesTranslations,
445
- this.entitiesConfig
456
+ mergedConfig
446
457
  );
447
458
  }
448
459
  if(!this.processedEntities?.entities){
@@ -247,12 +247,12 @@ class TemplateReloader
247
247
  if(!this.shouldReloadAdminTemplates(this.mappedAdminTemplates)){
248
248
  return false;
249
249
  }
250
- let newAdminFilesContents = await this.adminTemplatesLoader.fetchAdminFilesContents(this.mappedAdminTemplates);
251
- if(!newAdminFilesContents){
250
+ let adminFilesContents = await this.adminTemplatesLoader.fetchAdminFilesContents(this.mappedAdminTemplates);
251
+ if(!adminFilesContents){
252
252
  return false;
253
253
  }
254
254
  this.markTemplatesAsReloaded(this.mappedAdminTemplates);
255
- return newAdminFilesContents;
255
+ return adminFilesContents;
256
256
  }
257
257
 
258
258
  async checkAndReloadFrontendTemplates()
@@ -265,16 +265,16 @@ class TemplateReloader
265
265
  return true;
266
266
  }
267
267
 
268
- async updateAdminContentsAfterReload(newAdminFilesContents, adminManager)
268
+ async updateAdminContentsAfterReload(adminFilesContents, adminManager)
269
269
  {
270
- if(!newAdminFilesContents || !adminManager){
270
+ if(!adminFilesContents || !adminManager){
271
271
  return false;
272
272
  }
273
- adminManager.adminFilesContents = newAdminFilesContents;
274
- adminManager.contentsBuilder.adminFilesContents = newAdminFilesContents;
275
- adminManager.routerContents.adminFilesContents = newAdminFilesContents;
276
- adminManager.addCacheButtonSubscriber.cacheCleanButton = newAdminFilesContents.cacheCleanButton;
277
- adminManager.addCacheButtonSubscriber.clearAllCacheButton = newAdminFilesContents.clearAllCacheButton;
273
+ adminManager.adminFilesContents = adminFilesContents;
274
+ adminManager.contentsBuilder.adminFilesContents = adminFilesContents;
275
+ adminManager.routerContents.adminFilesContents = adminFilesContents;
276
+ adminManager.addCacheButtonSubscriber.cacheCleanButton = adminFilesContents.cacheCleanButton;
277
+ adminManager.addCacheButtonSubscriber.clearAllCacheButton = adminFilesContents.clearAllCacheButton;
278
278
  await adminManager.contentsBuilder.buildAdminContents();
279
279
  return true;
280
280
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reldens/cms",
3
3
  "scope": "@reldens",
4
- "version": "0.48.0",
4
+ "version": "0.50.0",
5
5
  "description": "Reldens - CMS",
6
6
  "author": "Damian A. Pastorini",
7
7
  "license": "MIT",
@@ -7,19 +7,24 @@
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes, viewport-fit=cover"/>
8
8
  <meta name="robots" content="{{meta_robots}}">
9
9
  <meta name="theme-color" content="{{meta_theme_color}}"/>
10
+ <meta property="og:title" content="{{meta_og_title}}"/>
11
+ <meta property="og:type" content="website"/>
12
+ <meta property="og:site_name" content="{{siteHandle}}"/>
13
+ <meta property="og:url" content="{{currentRequest.fullUrl}}"/>
14
+ <meta property="og:locale" content="{{locale}}"/>
10
15
  {{#meta_description}}
11
16
  <meta name="description" content="{{meta_description}}"/>
17
+ <meta property="og:description" content="{{meta_description}}"/>
12
18
  {{/meta_description}}
13
- <meta property="og:title" content="{{meta_og_title}}"/>
14
- {{#meta_og_description}}
15
- <meta property="og:description" content="{{meta_og_description}}"/>
16
- {{/meta_og_description}}
17
19
  {{#meta_og_image}}
18
20
  <meta property="og:image" content="{{meta_og_image}}"/>
19
21
  {{/meta_og_image}}
20
22
  {{#meta_twitter_card_type}}
21
23
  <meta name="twitter:card" content="{{meta_twitter_card_type}}"/>
22
24
  {{/meta_twitter_card_type}}
25
+ {{#article_author}}
26
+ <meta property="article:author" content="{{article_author}}"/>
27
+ {{/article_author}}
23
28
  {{#publish_date}}
24
29
  <meta name="publish-date" content="{{publish_date}}"/>
25
30
  {{/publish_date}}
@@ -34,14 +39,13 @@
34
39
  <link rel="icon" href="[asset(/favicons/default/favicon.ico)]" type="image/x-icon"/>
35
40
  <link rel="shortcut icon" href="[asset(/favicons/default/favicon.ico)]" type="image/x-icon">
36
41
  <link rel="manifest" href="[asset(/favicons/default/site.webmanifest)]"/>
37
- <!-- CSS and JS -->
38
- <link rel="stylesheet" href="[url(/css/styles.css)]"/>
39
- <script defer src="[url(/js/cookie-consent.js)]"></script>
42
+ <link rel="stylesheet" href="[cdn(/css/styles.css)]"/>
40
43
  </head>
41
44
  <body class="{{siteHandle}}">
42
- {{&content}}
43
- {{>cookie-consent}}
44
- <script defer type="text/javascript" src="[url(/js/functions.js)]"></script>
45
- <script defer type="text/javascript" src="[url(/js/scripts.js)]"></script>
45
+ {{&content}}
46
+ {{>cookie-consent}}
47
+ <script defer type="text/javascript" src="[cdn(/js/functions.js)]"></script>
48
+ <script defer type="text/javascript" src="[cdn(/js/scripts.js)]"></script>
49
+ <script defer src="[cdn(/js/cookie-consent.js)]"></script>
46
50
  </body>
47
51
  </html>