desy-html 17.0.0 → 17.0.1

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/docs/index.html CHANGED
@@ -147,6 +147,11 @@ cd desy-html</code></pre>
147
147
 
148
148
  <h2>Changelog (English)</h2>
149
149
  <p>What's new in the latest version of desy-html</p>
150
+ <h3>v.17.0.1</h3>
151
+ <ul class="text-sm">
152
+ <li>Security fixes from deepsec.</li>
153
+ <li>Fixed a bug that avoid using breakpoint variants in desy-html-starter.</li>
154
+ </ul>
150
155
  <h3>v.17.0.0</h3>
151
156
  <ul class="text-sm">
152
157
  <li>Added Combobox component.</li>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "desy-html",
3
- "version": "17.0.0",
3
+ "version": "17.0.1",
4
4
  "description": "desy-html contains the code you need to start building a user interface for Gobierno de Aragón government webapps.",
5
5
  "type": "module",
6
6
  "author": {
@@ -40,13 +40,13 @@
40
40
  "@floating-ui/dom": "^1.6.13",
41
41
  "@tailwindcss/forms": "^0.5.10",
42
42
  "@tailwindcss/typography": "^0.5.18",
43
- "@tailwindcss/vite": "^4.1.17",
43
+ "@tailwindcss/vite": "^4.2",
44
44
  "autoprefixer": "^10.4.21",
45
45
  "cally": "^0.8.0",
46
46
  "chokidar": "^3.6.0",
47
47
  "hex-rgb": "^5.0.0",
48
48
  "js-yaml": "^4.1.0",
49
- "tailwindcss": "^4.1.17"
49
+ "tailwindcss": "^4.2"
50
50
  },
51
51
  "devDependencies": {
52
52
  "cross-env": "^10.1.0",
@@ -36,18 +36,16 @@ export function MenuHorizontal(aria) {
36
36
  aria.MenuHorizontal.prototype.deactivateElement = function (elementDeactivated) {
37
37
  elementDeactivated.removeAttribute('aria-current');
38
38
  elementDeactivated.classList.remove('c-menu-horizontal__active');
39
- if(elementDeactivated.querySelector('strong')) {
40
- const getStrong = elementDeactivated.querySelector('strong');
41
- getStrong.classList.remove('font-bold');
42
- const replaceStrong = elementDeactivated.innerHTML.replace('strong', 'span');
43
- elementDeactivated.innerHTML = `${replaceStrong}`;
39
+ const getStrong = elementDeactivated.querySelector('strong');
40
+ if (getStrong) {
41
+ getStrong.replaceWith(...getStrong.childNodes);
44
42
  }
45
43
  };
46
44
 
47
45
  window.activateItemMenuHorizontal = function (menuId, activeItemId) {
48
46
  const menu = document.getElementById(menuId);
49
47
  if (menu) {
50
- const activeItem = document.querySelector(`#${menuId} #${activeItemId}`);
48
+ const activeItem = menu.querySelector('#' + CSS.escape(activeItemId));
51
49
  if (activeItem) {
52
50
  const menuInstance = new aria.MenuHorizontal(menu);
53
51
  menuInstance.activateElement(activeItemId);
@@ -84,6 +84,9 @@ export function MenuNavigation(aria) {
84
84
  const getAllLiElements = this.rootNode.querySelectorAll('li');
85
85
  [...getAllLiElements].forEach((element) => {
86
86
  const getElement = element.querySelector('a');
87
+ if (!getElement) {
88
+ return;
89
+ }
87
90
  if (getElement.id === elementActive) {
88
91
  this.wrapActiveElement(getElement);
89
92
  } else {
@@ -93,7 +96,7 @@ export function MenuNavigation(aria) {
93
96
  };
94
97
 
95
98
  aria.MenuNavigation.prototype.activateSubElement = function (menuId, elementActive) {
96
- const getAllLinks = this.rootNode.querySelectorAll(`#${menuId} ul li li a`);
99
+ const getAllLinks = this.rootNode.querySelectorAll(`#${CSS.escape(menuId)} ul li li a`);
97
100
  [...getAllLinks].forEach((link) => {
98
101
  if(link.id === elementActive) {
99
102
  link.setAttribute('aria-current', 'page');
@@ -104,10 +107,10 @@ export function MenuNavigation(aria) {
104
107
  getElementParent.removeAttribute('aria-current');
105
108
  const mainSpanParent2 = getElementParent.querySelector('span:not(.sr-only)');
106
109
  if (mainSpanParent2) {
107
- mainSpanParent2.innerHTML = mainSpanParent2.textContent;
110
+ unwrapStrong(mainSpanParent2);
108
111
  }
109
112
  link.removeAttribute('aria-current');
110
- link.innerHTML = `${link.textContent}`;
113
+ unwrapStrong(link);
111
114
  }
112
115
  });
113
116
  [...getAllLinks].forEach((link) => {
@@ -129,16 +132,29 @@ export function MenuNavigation(aria) {
129
132
  elementActive.innerHTML = `<strong class="font-bold">${elementActive.textContent}</strong>`;
130
133
  };
131
134
 
135
+ // Unwrap the <strong> of `node` in place, preserving the rest of its markup.
136
+ // If there is no <strong> (e.g. a never-activated sibling with its own
137
+ // icon/span), the node is left untouched instead of flattened to text.
138
+ function unwrapStrong(node) {
139
+ if (!node) return;
140
+ const strong = node.querySelector('strong');
141
+ if (!strong) return;
142
+ while (strong.firstChild) {
143
+ strong.parentNode.insertBefore(strong.firstChild, strong);
144
+ }
145
+ strong.remove();
146
+ }
147
+
132
148
  aria.MenuNavigation.prototype.deactivateElement = function (elementDeactivated) {
133
149
  elementDeactivated.removeAttribute('aria-current');
134
150
  elementDeactivated.classList.remove('c-menu-navigation__button--primary', 'c-menu-navigation__button--has-selection');
135
- elementDeactivated.innerHTML = elementDeactivated.textContent;
151
+ unwrapStrong(elementDeactivated);
136
152
  };
137
153
 
138
154
  window.activateItemMenuNavigation = function (menuId, activeItemId) {
139
155
  const menu = document.getElementById(menuId);
140
156
  if (menu) {
141
- const activeItem = document.querySelector(`#${menuId} #${activeItemId}`);
157
+ const activeItem = menu.querySelector('#' + CSS.escape(activeItemId));
142
158
  if (activeItem) {
143
159
  const menuInstance = new aria.MenuNavigation(menu);
144
160
  menuInstance.activateElement(activeItemId);
@@ -156,7 +172,7 @@ export function MenuNavigation(aria) {
156
172
  window.activateSubItemMenuNavigation = function (menuId, activeSubItemId) {
157
173
  const menu = document.getElementById(menuId);
158
174
  if (menu) {
159
- const activeSubItem = document.querySelector(`#${menuId} #${activeSubItemId}`);
175
+ const activeSubItem = menu.querySelector('#' + CSS.escape(activeSubItemId));
160
176
  if (activeSubItem) {
161
177
  const menuInstance = new aria.MenuNavigation(menu);
162
178
  menuInstance.activateSubElement(menuId, activeSubItemId);
@@ -327,51 +343,4 @@ export function MenuNavigation(aria) {
327
343
  aria.MenuNavigation.prototype.updateKeyControls = function (useArrowKeys) {
328
344
  this.useArrowKeys = useArrowKeys;
329
345
  };
330
-
331
- window.addEventListener(
332
- 'load',
333
- function () {
334
- var menus = document.querySelectorAll('.disclosure-nav');
335
- var disclosureMenus = [];
336
-
337
- for (var i = 0; i < menus.length; i++) {
338
- disclosureMenus[i] = new DisclosureNav(menus[i]);
339
- }
340
-
341
- // listen to arrow key checkbox
342
- var arrowKeySwitch = document.getElementById('arrow-behavior-switch');
343
- if (arrowKeySwitch) {
344
- arrowKeySwitch.addEventListener('change', function () {
345
- var checked = arrowKeySwitch.checked;
346
- for (var i = 0; i < disclosureMenus.length; i++) {
347
- disclosureMenus[i].updateKeyControls(checked);
348
- }
349
- });
350
- }
351
-
352
- // fake link behavior
353
- disclosureMenus.forEach((disclosureNav, i) => {
354
- var links = menus[i].querySelectorAll('[href="#mythical-page-content"]');
355
- var examplePageHeading = document.getElementById('mythical-page-heading');
356
- for (var k = 0; k < links.length; k++) {
357
- // The codepen export script updates the internal link href with a full URL
358
- // we're just manually fixing that behavior here
359
- links[k].href = '#mythical-page-content';
360
-
361
- links[k].addEventListener('click', (event) => {
362
- // change the heading text to fake a page change
363
- var pageTitle = event.target.innerText;
364
- examplePageHeading.innerText = pageTitle;
365
-
366
- // handle aria-current
367
- for (var n = 0; n < links.length; n++) {
368
- links[n].removeAttribute('aria-current');
369
- }
370
- event.target.setAttribute('aria-current', 'page');
371
- });
372
- }
373
- });
374
- },
375
- false
376
- );
377
346
  }
@@ -33,16 +33,16 @@ export function MenuVertical(aria) {
33
33
 
34
34
  aria.MenuVertical.prototype.deactivateElement = function (elementDeactivated) {
35
35
  elementDeactivated.removeAttribute('aria-current');
36
- if(elementDeactivated.querySelector('strong')) {
37
- const replaceStrong = elementDeactivated.innerHTML.replace('<strong class="font-bold">', '').replace('<strong/>', '');
38
- elementDeactivated.innerHTML = `${replaceStrong}`;
36
+ const getStrong = elementDeactivated.querySelector('strong');
37
+ if (getStrong) {
38
+ getStrong.replaceWith(...getStrong.childNodes);
39
39
  }
40
40
  };
41
41
 
42
42
  window.activateItemMenuVertical = function (menuId, activeItemId) {
43
43
  const menu = document.getElementById(menuId);
44
44
  if (menu) {
45
- const activeItem = document.querySelector(`#${menuId} #${activeItemId}`);
45
+ const activeItem = menu.querySelector('#' + CSS.escape(activeItemId));
46
46
  if (activeItem) {
47
47
  const menuInstance = new aria.MenuVertical(menu);
48
48
  menuInstance.activateElement(activeItemId);
@@ -24,7 +24,7 @@ export function MenubarAction(aria) {
24
24
  var msgPrefix = 'Menubar constructor argument domNode ';
25
25
 
26
26
  // Check whether domNode is a DOM element
27
- if (!domNode instanceof Element) {
27
+ if (!(domNode instanceof Element)) {
28
28
  throw new TypeError(msgPrefix + 'is not a DOM Element.');
29
29
  }
30
30
 
@@ -218,14 +218,14 @@ export function MenubarAction(aria) {
218
218
  if (!this.isItemDisabled(this.menubarItems[index])) {
219
219
  this.menubarItems[index].domNode.focus();
220
220
  this.menubarItems[index].domNode.tabIndex = 0;
221
- currentItem.tabIndex = -1;
221
+ currentItem.domNode.tabIndex = -1;
222
222
  } else {
223
223
  // Find the next enabled item that starts with the same character
224
224
  var nextEnabledIndex = this.findNextEnabledItemWithChar(index, char);
225
225
  if (nextEnabledIndex !== -1) {
226
226
  this.menubarItems[nextEnabledIndex].domNode.focus();
227
227
  this.menubarItems[nextEnabledIndex].domNode.tabIndex = 0;
228
- currentItem.tabIndex = -1;
228
+ currentItem.domNode.tabIndex = -1;
229
229
  }
230
230
  }
231
231
  }
@@ -293,7 +293,7 @@ export function MenubarAction(aria) {
293
293
  };
294
294
 
295
295
  aria.MenubarAction.prototype.activateElements = function (menuId, activateElements) {
296
- const getAllElements = this.domNode.querySelectorAll(`#${menuId} ul > li `);
296
+ const getAllElements = this.domNode.querySelectorAll(`#${CSS.escape(menuId)} ul > li `);
297
297
  const filterElements = [...getAllElements].filter((element) => element.id !== '' && element.getAttribute('role') !== 'separator');
298
298
  [...filterElements].forEach((element) => {
299
299
  if(isActive(element)) {
@@ -305,7 +305,7 @@ export function MenubarAction(aria) {
305
305
  : element.parentElement.parentElement.querySelector('a');
306
306
 
307
307
  getElementParent.classList.remove('c-menubar__button--has-selection');
308
- getElementParent.firstChild.innerHTML = `${getElementParent.textContent}`;
308
+ unwrapStrong(getElementParent.firstElementChild);
309
309
  element.setAttribute('aria-checked', 'false');
310
310
  element.innerHTML = `${element.textContent}`;
311
311
  }
@@ -316,10 +316,33 @@ export function MenubarAction(aria) {
316
316
  ? element.parentElement.parentElement.parentElement.parentElement.querySelector('a')
317
317
  : element.parentElement.parentElement.querySelector('a');
318
318
  getElementParent.classList.add('c-menubar__button--has-selection');
319
- getElementParent.firstChild.innerHTML = `<strong class="font-bold">${getElementParent.textContent}</strong>`;
319
+ wrapStrong(getElementParent.firstElementChild);
320
320
  }
321
321
  });
322
322
 
323
+ // Wrap the existing children of `node` in a <strong> in place, preserving
324
+ // any markup the consumer put in the label (icons, SVG, spans).
325
+ function wrapStrong(node) {
326
+ if (!node || node.querySelector('strong')) return;
327
+ const strong = document.createElement('strong');
328
+ strong.className = 'font-bold';
329
+ while (node.firstChild) {
330
+ strong.appendChild(node.firstChild);
331
+ }
332
+ node.appendChild(strong);
333
+ }
334
+
335
+ // Unwrap the <strong> of `node` in place, preserving the rest of its markup.
336
+ function unwrapStrong(node) {
337
+ if (!node) return;
338
+ const strong = node.querySelector('strong');
339
+ if (!strong) return;
340
+ while (strong.firstChild) {
341
+ strong.parentNode.insertBefore(strong.firstChild, strong);
342
+ }
343
+ strong.remove();
344
+ }
345
+
323
346
  function isActive(element){
324
347
  const { id } = element;
325
348
  return typeof activateElements === "string"
@@ -65,8 +65,7 @@ export function MenubarItemAction(aria) {
65
65
  };
66
66
 
67
67
  aria.MenubarItemAction.prototype.handleKeydown = function (event) {
68
- var tgt = event.currentTarget,
69
- char = event.key,
68
+ var char = event.key,
70
69
  flag = false;
71
70
 
72
71
  function isPrintableCharacter (str) {
@@ -127,13 +126,6 @@ export function MenubarItemAction(aria) {
127
126
  flag = true;
128
127
  break;
129
128
 
130
- case this.keyCode.ESC:
131
- if (this.popupMenu) {
132
- this.popupMenu.close();
133
- }
134
- flag = true;
135
- break;
136
-
137
129
  default:
138
130
  if (isPrintableCharacter(char)) {
139
131
  this.menubar.setFocusByFirstCharacter(this, char);
@@ -33,16 +33,16 @@ export function Nav(aria) {
33
33
 
34
34
  aria.Nav.prototype.deactivateElement = function (elementDeactivated) {
35
35
  elementDeactivated.removeAttribute('aria-current');
36
- if(elementDeactivated.querySelector('strong')) {
37
- const replaceStrong = elementDeactivated.innerHTML.replace('<strong class="font-bold">', '').replace('<strong/>', '');
38
- elementDeactivated.innerHTML = `${replaceStrong}`;
36
+ const getStrong = elementDeactivated.querySelector('strong');
37
+ if (getStrong) {
38
+ getStrong.replaceWith(...getStrong.childNodes);
39
39
  }
40
40
  };
41
41
 
42
42
  window.activateItemNav = function (menuId, activeItemId) {
43
43
  const menu = document.getElementById(menuId);
44
44
  if (menu) {
45
- const activeItem = document.querySelector(`#${menuId} #${activeItemId}`);
45
+ const activeItem = menu.querySelector('#' + CSS.escape(activeItemId));
46
46
  if (activeItem) {
47
47
  const menuInstance = new aria.Nav(menu);
48
48
  menuInstance.activateElement(activeItemId);
@@ -35,7 +35,7 @@ export function PopupMenuAction(aria) {
35
35
  msgPrefix = 'PopupMenu constructor argument domNode ';
36
36
 
37
37
  // Check whether domNode is a DOM element
38
- if (!domNode instanceof Element) {
38
+ if (!(domNode instanceof Element)) {
39
39
  throw new TypeError(msgPrefix + 'is not a DOM Element.');
40
40
  }
41
41
 
@@ -71,7 +71,7 @@ export function PopupMenuAction(aria) {
71
71
  this.domNode.setAttribute('role', 'menu');
72
72
 
73
73
  if (!this.domNode.getAttribute('aria-labelledby') && !this.domNode.getAttribute('aria-label') && !this.domNode.getAttribute('title')) {
74
- label = this.controller.domNode.innerHTML;
74
+ label = this.controller.domNode.textContent.trim();
75
75
  this.domNode.setAttribute('aria-label', label);
76
76
  }
77
77
 
@@ -159,7 +159,8 @@ export function accordion(aria) {
159
159
  const getAllElementsHide = target.parentElement.parentElement.querySelectorAll('.c-accordion__hide')
160
160
  const getAllPanels = target.parentElement.parentElement.querySelectorAll('.c-accordion__panel')
161
161
  const getAllTriggers = target.parentElement.parentElement.querySelectorAll('.c-accordion__trigger')
162
- if(target.textContent.includes('Mostrar todo') || show === true) {
162
+ var shouldShow = (show === null) ? target.textContent.includes('Mostrar todo') : (show === true);
163
+ if(shouldShow) {
163
164
  getAllElementsShow.forEach(element => {
164
165
  element.classList.add('hidden')
165
166
  })
@@ -205,17 +206,33 @@ export function accordion(aria) {
205
206
 
206
207
  window.activateItemAccordion = function (menuId, activeItemId) {
207
208
  const menu = document.getElementById(menuId);
208
- if (menu) {
209
- const activeItem = document.querySelector(`#${menuId} #${activeItemId}`);
210
- if (activeItem) {
209
+ if (!menu) {
210
+ console.log('There is no accordion with this id in the document.');
211
+ return null;
212
+ }
213
+
214
+ if (Array.isArray(activeItemId)) {
215
+ const activeItems = activeItemId.map(function (id) {
216
+ return document.getElementById(id);
217
+ });
218
+ const allInMenu = activeItems.every(function (item) {
219
+ return item && menu.contains(item);
220
+ });
221
+ if (allInMenu) {
211
222
  activateElement(menuId, activeItemId);
212
- return [menu, activeItem]
223
+ return [menu, activeItems];
213
224
  } else {
214
225
  console.log('There is no item with this id in the menu.');
215
226
  return null;
216
227
  }
228
+ }
229
+
230
+ const activeItem = document.getElementById(activeItemId);
231
+ if (activeItem && menu.contains(activeItem)) {
232
+ activateElement(menuId, activeItemId);
233
+ return [menu, activeItem]
217
234
  } else {
218
- console.log('There is no accordion with this id in the document.');
235
+ console.log('There is no item with this id in the menu.');
219
236
  return null;
220
237
  }
221
238
  };
@@ -232,10 +249,10 @@ export function accordion(aria) {
232
249
  };
233
250
 
234
251
  function activateElement(menu, activeItem) {
235
- const getAccordion = document.querySelector(`#${menu}`);
252
+ const getAccordion = document.getElementById(menu);
236
253
  const allowMultiple = getAccordion.hasAttribute('data-allow-multiple');
237
- const allowToggle = (allowMultiple) ? allowMultiple : accordion.hasAttribute('data-allow-toggle');
238
- const selectAllTriggers = document.querySelectorAll(`#${menu} .c-accordion__trigger`);
254
+ const allowToggle = (allowMultiple) ? allowMultiple : getAccordion.hasAttribute('data-allow-toggle');
255
+ const selectAllTriggers = getAccordion.querySelectorAll('.c-accordion__trigger');
239
256
  [...selectAllTriggers].forEach((trigger) => {
240
257
  const getPanel = trigger.parentElement.parentElement.querySelector('.c-accordion__panel');
241
258
  const getShowMessage = trigger.querySelector('.c-accordion__show');
@@ -255,7 +272,7 @@ export function accordion(aria) {
255
272
 
256
273
  function isActive(element){
257
274
  const { id } = element;
258
- return typeof activeItem === "object" && allowToggle
275
+ return Array.isArray(activeItem)
259
276
  ? activeItem.includes(id)
260
277
  : id === activeItem;
261
278
  }
@@ -5,8 +5,15 @@ export function alert(aria) {
5
5
  throw new Error('No element found with id="' + alertId + '".');
6
6
  }
7
7
 
8
+ var templateNode = document.getElementById('template-' + alertId);
9
+ if (templateNode === null) {
10
+ throw new Error('No template found with id="template-' + alertId + '".');
11
+ }
12
+
13
+ this.alertNode.innerHTML = templateNode.innerHTML;
8
14
 
9
- document.getElementById(alertId).innerHTML = document.getElementById('template-' + alertId).innerHTML;
15
+ // Capture the previously focused element before moving focus below.
16
+ this.lastFocus = document.activeElement;
10
17
 
11
18
  if (typeof focusAfterClosed === 'string') {
12
19
  this.focusAfterClosed = document.getElementById(focusAfterClosed);
@@ -32,8 +39,6 @@ export function alert(aria) {
32
39
  if (this.focusFirst) {
33
40
  this.focusFirst.focus();
34
41
  }
35
-
36
- this.lastFocus = document.activeElement;
37
42
  }; // end Alert constructor
38
43
 
39
44
  window.openAlert = function (alertId, focusAfterClosed, focusFirst) {
@@ -147,9 +147,17 @@ export function combobox(aria) {
147
147
  }
148
148
 
149
149
  if (text.length === 0 && !this.multiselectable) {
150
+ var hadSelection = this.selectedValue || this.hiddenInputNode.value;
150
151
  for (var i = 0; i < this.allOptions.length; i++) {
151
152
  this.allOptions[i].node.setAttribute('aria-selected', 'false');
152
153
  }
154
+ this.selectedValue = '';
155
+ this.selectedId = '';
156
+ this.hiddenInputNode.value = '';
157
+ this.hiddenInputNode.setAttribute('value', '');
158
+ if (hadSelection) {
159
+ this.dispatchCustomEvent('combobox:clear', {});
160
+ }
153
161
  }
154
162
 
155
163
  this._isDeleting = false;
@@ -569,10 +577,15 @@ export function combobox(aria) {
569
577
 
570
578
  var inputNode = comboboxNode.querySelector('[data-module="c-combobox-input"]');
571
579
  if (inputNode && inputNode._comboboxInstance) {
572
- if (inputNode._comboboxInstance.multiselectable) {
573
- inputNode._comboboxInstance.toggleOption(option);
580
+ var instance = inputNode._comboboxInstance;
581
+ if (!instance.listboxNode.contains(option)) {
582
+ console.log('The option does not belong to the target combobox.');
583
+ return null;
584
+ }
585
+ if (instance.multiselectable) {
586
+ instance.toggleOption(option);
574
587
  } else {
575
- inputNode._comboboxInstance.selectOption(option);
588
+ instance.selectOption(option);
576
589
  }
577
590
  return [comboboxNode, option];
578
591
  }
@@ -524,33 +524,34 @@ export function dataGrid(aria) {
524
524
  sortType = aria.SortType.ASCENDING;
525
525
  }
526
526
 
527
- var comparator = function (row1, row2) {
528
- if(row1.children[columnIndex].classList.contains('align-top') || row2.children[columnIndex].classList.contains('align-top')) {
529
- return
530
- }
531
- var row1Text = row1.children[columnIndex].innerText;
532
- var row2Text = row2.children[columnIndex].innerText;
533
- var row1Value = parseInt(row1Text) ? parseInt(row1Text.replace(/[^0-9\.]+/g, '')) : row1Text
534
- var row2Value = parseInt(row2Text) ? parseInt(row2Text.replace(/[^0-9\.]+/g, '')) : row2Text
535
-
536
- var orderValue = parseInt(row1Text) ? 'numbers' : 'strings'
537
-
538
- if (sortType === aria.SortType.ASCENDING && orderValue === 'numbers') {
539
- return row1Value - row2Value;
527
+ function parseNumeric(text) {
528
+ var cleaned = text.replace(/[^0-9.\-]+/g, ''); // conserva dígitos, punto y signo
529
+ if (cleaned === '' || cleaned === '-' || cleaned === '.') {
530
+ return NaN;
540
531
  }
532
+ return parseFloat(cleaned);
533
+ }
541
534
 
542
- if (sortType === aria.SortType.DESCENDING && orderValue === 'numbers') {
543
- return row2Value - row1Value;
535
+ var comparator = function (row1, row2) {
536
+ var cell1 = row1.children[columnIndex];
537
+ var cell2 = row2.children[columnIndex];
538
+ if (cell1.classList.contains('align-top') || cell2.classList.contains('align-top')) {
539
+ return 0;
544
540
  }
541
+ var row1Text = cell1.innerText;
542
+ var row2Text = cell2.innerText;
545
543
 
546
- if (sortType === aria.SortType.ASCENDING && orderValue === 'strings') {
547
- return row1Value.toString().localeCompare(row2Value);
548
- }
544
+ var row1Value = parseNumeric(row1Text);
545
+ var row2Value = parseNumeric(row2Text);
549
546
 
550
- if (sortType === aria.SortType.DESCENDING && orderValue === 'strings') {
551
- return row2Value.toString().localeCompare(row1Value);
547
+ var result;
548
+ if (!isNaN(row1Value) && !isNaN(row2Value)) {
549
+ result = row1Value - row2Value; // signo y cero correctos
550
+ } else {
551
+ result = row1Text.toString().localeCompare(row2Text.toString()); // fallback string
552
552
  }
553
553
 
554
+ return (sortType === aria.SortType.ASCENDING) ? result : -result;
554
555
  };
555
556
 
556
557
  this.sortRows(comparator);
@@ -574,9 +575,11 @@ export function dataGrid(aria) {
574
575
  * Comparison function to sort the rows
575
576
  */
576
577
  aria.Grid.prototype.sortRows = function (compareFn) {
577
- var rows = this.gridNode.querySelectorAll("tbody tr");
578
+ var rows = Array.prototype.slice.call(this.gridNode.querySelectorAll("tbody tr"));
578
579
  var rowWrapper = this.gridNode.querySelector('tbody');
579
- var dataRows = Array.prototype.slice.call(rows, 1);
580
+ // La fila de filtro contiene controles de búsqueda/selección; las filas de datos no.
581
+ var hasFilterRow = rows.length > 0 && rows[0].querySelector('input[type="search"], select');
582
+ var dataRows = hasFilterRow ? rows.slice(1) : rows;
580
583
 
581
584
  dataRows.sort(compareFn);
582
585
 
@@ -31,12 +31,34 @@ export function LinksList(aria) {
31
31
 
32
32
  function wrapActiveElement(elementActive) {
33
33
  const getText = elementActive.querySelector('div[data-element="c-links-list__text"]');
34
- getText.innerHTML = `<strong class="font-bold">${elementActive.textContent}</strong>`;
34
+ wrapStrong(getText);
35
35
  };
36
36
 
37
37
  function deactivateElement(elementDeactivated) {
38
38
  const getText = elementDeactivated.querySelector('div[data-element="c-links-list__text"]');
39
- const replaceStrong = getText.textContent.replace('<strong class="font-bold">', '').replace('<strong/>', '');
40
- getText.textContent = `${replaceStrong}`;
39
+ unwrapStrong(getText);
40
+ };
41
+
42
+ // Wrap the existing children of `node` in a <strong> in place, preserving
43
+ // any markup the consumer passed in item.html (icons, spans, etc.).
44
+ function wrapStrong(node) {
45
+ if (!node || node.querySelector('strong')) return;
46
+ const strong = document.createElement('strong');
47
+ strong.className = 'font-bold';
48
+ while (node.firstChild) {
49
+ strong.appendChild(node.firstChild);
50
+ }
51
+ node.appendChild(strong);
52
+ };
53
+
54
+ // Unwrap the <strong> of `node` in place, preserving the rest of its markup.
55
+ function unwrapStrong(node) {
56
+ if (!node) return;
57
+ const strong = node.querySelector('strong');
58
+ if (!strong) return;
59
+ while (strong.firstChild) {
60
+ strong.parentNode.insertBefore(strong.firstChild, strong);
61
+ }
62
+ strong.remove();
41
63
  };
42
64
  }
@@ -463,7 +463,7 @@ export function listbox(aria) {
463
463
  return;
464
464
  }
465
465
 
466
- currentItem = document.getElementById(this.activeDescendant);
466
+ var currentItem = document.getElementById(this.activeDescendant);
467
467
  previousItem = currentItem.previousElementSibling;
468
468
 
469
469
  if (previousItem) {
@@ -486,7 +486,7 @@ export function listbox(aria) {
486
486
  return;
487
487
  }
488
488
 
489
- currentItem = document.getElementById(this.activeDescendant);
489
+ var currentItem = document.getElementById(this.activeDescendant);
490
490
  nextItem = currentItem.nextElementSibling;
491
491
 
492
492
  if (nextItem) {
@@ -565,7 +565,7 @@ export function listbox(aria) {
565
565
  if (isActive(element, activeItem)) {
566
566
  element.setAttribute('aria-selected', 'true');
567
567
  if (!allowMultiple && getListBoxButton.dataset.change) {
568
- getListBoxButtonText.innerHTML = element.textContent;
568
+ getListBoxButtonText.textContent = element.textContent;
569
569
  }
570
570
  } else {
571
571
  element.setAttribute('aria-selected', 'false');
@@ -229,7 +229,7 @@ export function tabs(aria) {
229
229
 
230
230
  // Detect if a tab is deletable
231
231
  function determineDeletable (event) {
232
- target = event.target;
232
+ var target = event.target;
233
233
 
234
234
  if (target.getAttribute('data-deletable') !== null) {
235
235
  // Delete target tab
@@ -251,7 +251,8 @@ export function tabs(aria) {
251
251
  // Deletes a tab and its panel
252
252
  function deleteTab (event) {
253
253
  var target = event.target;
254
- var panel = elem.getElementById(target.getAttribute('aria-controls'));
254
+ var controls = target.getAttribute('aria-controls');
255
+ var panel = elem.querySelector('#' + controls);
255
256
 
256
257
  target.parentElement.removeChild(target);
257
258
  panel.parentElement.removeChild(panel);
@@ -281,17 +282,30 @@ export function tabs(aria) {
281
282
  //Add active class to current tab
282
283
  element.classList.add("c-tabs__link--is-active");
283
284
  element.setAttribute('role', 'tab');
285
+ // Wrap the existing children in a <strong> in place, preserving any
286
+ // markup the consumer put in the label (icons, SVG, spans).
284
287
  if(element.querySelector('strong') === null) {
285
- element.innerHTML = `<strong class="font-bold">${element.innerHTML}</strong>` ;
288
+ const strong = document.createElement('strong');
289
+ strong.className = 'font-bold';
290
+ while (element.firstChild) {
291
+ strong.appendChild(element.firstChild);
292
+ }
293
+ element.appendChild(strong);
286
294
  }
287
295
  }
288
296
 
289
297
  function removeActiveClass(element) {
290
298
  element.classList.remove("c-tabs__link--is-active");
291
299
  element.removeAttribute('role');
292
- if(element.querySelector('strong')) {
293
- const replaceStrong = element.innerHTML.replace('<strong class="font-bold">', '').replace('<strong/>', '');
294
- element.innerHTML = `${replaceStrong}`;
300
+ // Unwrap only the <strong> we added (direct child with font-bold),
301
+ // replacing it with its children. A consumer-provided <strong> nested
302
+ // in the label is left untouched.
303
+ const strong = element.querySelector(':scope > strong.font-bold');
304
+ if (strong) {
305
+ while (strong.firstChild) {
306
+ element.insertBefore(strong.firstChild, strong);
307
+ }
308
+ strong.remove();
295
309
  }
296
310
  }
297
311
 
@@ -1,3 +1,5 @@
1
+ import { escapeHtml, safeHref } from './utils.js';
2
+
1
3
  export function Tree(aria) {
2
4
  /*
3
5
  * This content is licensed according to the W3C Software License at
@@ -49,7 +51,7 @@ export function Tree(aria) {
49
51
  if (paramsRaw) {
50
52
  this.params = JSON.parse(paramsRaw);
51
53
  this.describedBy = this.params.describedBy ?? "";
52
- if (this.params.fieldset.describedBy)
54
+ if (this.params.fieldset?.describedBy)
53
55
  this.describedBy = this.params.fieldset.describedBy;
54
56
  this.hasFieldset = this.params.fieldset ?? false;
55
57
  }
@@ -95,6 +97,32 @@ export function Tree(aria) {
95
97
  }
96
98
  };
97
99
 
100
+ // Serializes dynamic attributes with a strict allow-list: only well-formed data-*/aria-*
101
+ // names or exactly {title, role, id, class}. The [a-z0-9-] pattern rejects keys with spaces,
102
+ // '=', quotes, '<', '>' or '/', closing the "data-x onmouseover=..." bypass. Both the name
103
+ // and the value are escaped.
104
+ function safeAttrs(obj) {
105
+ var out = '';
106
+ var dataAria = /^(data|aria)-[a-z0-9-]+$/;
107
+ var exact = { title: true, role: true, id: true, class: true };
108
+ for (var a in obj || {}) {
109
+ var key = String(a).toLowerCase();
110
+ if (!dataAria.test(key) && exact[key] !== true) continue;
111
+ out += ' ' + escapeHtml(a) + '="' + escapeHtml(obj[a]) + '"';
112
+ }
113
+ return out;
114
+ }
115
+
116
+ // Returns the consumer's intentional HTML (node.html) or, otherwise, the escaped text.
117
+ function htmlOrText(node) {
118
+ return node.html ? node.html : escapeHtml(node.text || '');
119
+ }
120
+
121
+ // Suffix ' escaped-class' to concatenate after fixed classes, or '' if none.
122
+ function optClasses(cls) {
123
+ return cls ? ' ' + escapeHtml(cls) : '';
124
+ }
125
+
98
126
  function checkboxItem(item, id, name, type, params, describedBy, hasFieldset) {
99
127
  name = name || item.name || '';
100
128
  var hasHint = item.hint && (item.hint.text || item.hint.html);
@@ -105,7 +133,7 @@ export function Tree(aria) {
105
133
  // Wrapper
106
134
  var html = '<div' +
107
135
  ((params.hasDividers ? ' class="border-t border-b border-neutral-base -mb-px' : '') +
108
- (item.classes ? ' ' + item.classes : '') + '"').replace('""', '') +
136
+ optClasses(item.classes) + '"').replace('""', '') +
109
137
  '>' +
110
138
  '<div class="relative flex items-start py-xs">';
111
139
 
@@ -120,13 +148,13 @@ export function Tree(aria) {
120
148
  ((item.isIndeterminate && item.indeterminateChecked)
121
149
  ? ' c-checkboxes__indeterminate-active'
122
150
  : '') + '"' +
123
- ' id="' + id + '-input"' +
124
- ' name="' + name + '"' +
151
+ ' id="' + escapeHtml(id) + '-input"' +
152
+ ' name="' + escapeHtml(name) + '"' +
125
153
  (type === 'checkbox' ? ' type="checkbox" role="checkbox"' : ' type="radio"') +
126
- ' value="' + (item.value || '') + '"' +
154
+ ' value="' + escapeHtml(item.value || '') + '"' +
127
155
  (item.checked ? ' checked' : '') +
128
156
  (item.disabled ? ' disabled' : '') +
129
- (itemDescribed ? ' aria-describedby="' + itemDescribed + '"' : '') +
157
+ (itemDescribed ? ' aria-describedby="' + escapeHtml(itemDescribed) + '"' : '') +
130
158
  (params.errorMessage ? ' aria-invalid="true"' : '');
131
159
 
132
160
  if (item.isIndeterminate || item.indeterminateChecked) {
@@ -136,62 +164,58 @@ export function Tree(aria) {
136
164
  '"';
137
165
  }
138
166
  html += '>' +
139
- '</div>' + // cierre flex items-center
167
+ '</div>' + // closes flex items-center
140
168
  '<div class="flex-1 pt-0.5 leading-5">';
141
169
 
142
170
  // Label
143
171
  var lblCls = (item.label && item.label.classes)
144
172
  ? item.label.classes
145
173
  : 'block relative -top-xs -left-8 pl-8 py-xs';
146
- html += '<label for="' + id + '-input" class="' + lblCls + '">';
147
- html += item.html ? item.html : (item.text || '');
174
+ html += '<label for="' + escapeHtml(id) + '-input" class="' + escapeHtml(lblCls) + '">';
175
+ html += htmlOrText(item);
148
176
  html += '</label>';
149
177
 
150
178
  // Hint
151
179
  if (hasHint) {
152
- html += '<div id="' + itemHintId + '"' +
153
- (item.hint.classes ? ' class="' + item.hint.classes + '"' : '') +
180
+ html += '<div id="' + escapeHtml(itemHintId) + '"' +
181
+ (item.hint.classes ? ' class="' + escapeHtml(item.hint.classes) + '"' : '') +
154
182
  '>';
155
- html += item.hint.html || item.hint.text;
183
+ html += htmlOrText(item.hint);
156
184
  html += '</div>';
157
185
  }
158
- html += '</div>'; // cierre flex-1
186
+ html += '</div>'; // closes flex-1
159
187
  }
160
188
 
161
189
  // Navigation branch
162
190
  if (type === 'navigation') {
163
191
  if (item.href) {
164
192
  html += '<a' +
165
- (id ? ' id="' + id + '-link"' : '') +
166
- ' href="' + item.href + '"' +
193
+ (id ? ' id="' + escapeHtml(id) + '-link"' : '') +
194
+ ' href="' + escapeHtml(safeHref(item.href)) + '"' +
167
195
  ' class="block px-xs focus:bg-warning-base focus:outline-hidden ' +
168
196
  'focus:shadow-outline-focus focus:text-black' +
169
- (item.classes ? ' ' + item.classes : '') +
197
+ optClasses(item.classes) +
170
198
  (!item.disabled ? ' hover:text-primary-base hover:underline'
171
199
  : ' no-underline pointer-events-none') + '"' +
172
- (item.title ? ' title="' + item.title + '"' : '') +
200
+ (item.title ? ' title="' + escapeHtml(item.title) + '"' : '') +
173
201
  (item.active ? ' aria-current="page"' : '') +
174
202
  (item.disabled ? ' aria-disabled="true" tabindex="-1"' : '') +
175
- (item.target ? ' target="' + item.target + '"' : '') +
203
+ (item.target ? ' target="' + escapeHtml(item.target) + '"' : '') +
176
204
  '>';
177
205
  html += item.active
178
- ? '<strong class="font-bold">' +
179
- (item.html || item.text) +
180
- '</strong>'
181
- : (item.html || item.text);
206
+ ? '<strong class="font-bold">' + htmlOrText(item) + '</strong>'
207
+ : htmlOrText(item);
182
208
  html += '</a>';
183
209
  } else {
184
210
  html += '<span' +
185
- (id ? ' id="' + id + '-link"' : '') +
211
+ (id ? ' id="' + escapeHtml(id) + '-link"' : '') +
186
212
  ' class="block px-xs' +
187
- (item.classes ? ' ' + item.classes : '') + '"' +
188
- (item.title ? ' title="' + item.title + '"' : '') +
213
+ optClasses(item.classes) + '"' +
214
+ (item.title ? ' title="' + escapeHtml(item.title) + '"' : '') +
189
215
  '>';
190
216
  html += item.active
191
- ? '<strong class="font-bold">' +
192
- (item.html || item.text) +
193
- '</strong>'
194
- : (item.html || item.text);
217
+ ? '<strong class="font-bold">' + htmlOrText(item) + '</strong>'
218
+ : htmlOrText(item);
195
219
  html += '</span>';
196
220
  }
197
221
  }
@@ -209,10 +233,8 @@ export function Tree(aria) {
209
233
  '<div class="flex items-center relative ' +
210
234
  'focus:bg-warning-base focus:outline-hidden ' +
211
235
  'focus:shadow-outline-focus focus:text-black text-left' +
212
- (itemNode.classes ? ' ' + itemNode.classes : '') + '"';
213
- for (var a in itemNode.attributes || {}) {
214
- html += ' ' + a + '="' + itemNode.attributes[a] + '"';
215
- }
236
+ optClasses(itemNode.classes) + '"';
237
+ html += safeAttrs(itemNode.attributes);
216
238
  html += '>' +
217
239
  '<span class="c-tree__icon absolute top-3 -left-4 flex items-center ' +
218
240
  'w-4 h-2.5 text-primary-base font-bold ' +
@@ -238,10 +260,8 @@ export function Tree(aria) {
238
260
 
239
261
  if (itemNode.sub && itemNode.sub.items) {
240
262
  html += '<ul role="group" class="c-tree__itemgroup' +
241
- (itemNode.sub.classes ? ' ' + itemNode.sub.classes : '') + '"';
242
- for (var b in itemNode.sub.attributes || {}) {
243
- html += ' ' + b + '="' + itemNode.sub.attributes[b] + '"';
244
- }
263
+ optClasses(itemNode.sub.classes) + '"';
264
+ html += safeAttrs(itemNode.sub.attributes);
245
265
  html += '>';
246
266
 
247
267
  if (!lazyRendering || onlySub) {
@@ -259,7 +279,7 @@ export function Tree(aria) {
259
279
  : '';
260
280
 
261
281
  html += '<li class="' + clsBase + '" ' +
262
- 'id="' + subId + '" ' +
282
+ 'id="' + escapeHtml(subId) + '" ' +
263
283
  'role="treeitem" ' +
264
284
  'data-module="c-tree__item"' +
265
285
  expanded +
@@ -279,10 +299,10 @@ export function Tree(aria) {
279
299
  );
280
300
  } else {
281
301
  html += '<div class="block' +
282
- (subitem.classes ? ' ' + subitem.classes : '') +
302
+ optClasses(subitem.classes) +
283
303
  (subitem.active ? ' font-bold' : '') +
284
304
  '"' +
285
- (subitem.title ? ' title="' + subitem.title + '"' : '') +
305
+ (subitem.title ? ' title="' + escapeHtml(subitem.title) + '"' : '') +
286
306
  (subitem.active ? ' aria-current="page"' : '') +
287
307
  '>';
288
308
  if (subitem.active) {
@@ -149,7 +149,7 @@ export function Treeitem(aria) {
149
149
  return;
150
150
  }
151
151
 
152
- if (event.shift) {
152
+ if (event.shiftKey) {
153
153
  if (isPrintableCharacter(char)) {
154
154
  printableCharacter(this);
155
155
  }
@@ -158,14 +158,18 @@ export function Treeitem(aria) {
158
158
  switch (event.keyCode) {
159
159
  case this.keyCode.SPACE:
160
160
  case this.keyCode.RETURN:
161
- const typeOfInput = getCurrentCheckbox.type
162
- if (typeOfInput === 'radio') {
163
- for (let item of getAllChildrenOfTree) {
164
- item.checked = false
161
+ // Navigation trees have no checkbox/radio: skip the toggle but still
162
+ // swallow the key so it doesn't propagate.
163
+ if (getCurrentCheckbox) {
164
+ const typeOfInput = getCurrentCheckbox.type
165
+ if (typeOfInput === 'radio') {
166
+ for (let item of getAllChildrenOfTree) {
167
+ item.checked = false
168
+ }
165
169
  }
170
+ getCurrentCheckbox.checked = getCurrentCheckbox.checked === true ? false : true
171
+ targetElement.setAttribute('aria-checked', getCurrentCheckbox.checked ? 'true' : 'false');
166
172
  }
167
- getCurrentCheckbox.checked = getCurrentCheckbox.checked === true ? false : true
168
- targetElement.setAttribute('aria-checked', getCurrentCheckbox.checked ? 'true' : 'false');
169
173
  flag = true;
170
174
  break;
171
175
 
@@ -279,11 +283,16 @@ export function Treeitem(aria) {
279
283
  };
280
284
 
281
285
  function arrayOrSingleElement(elementId, itemsIds, open) {
286
+ // Resolve the navigation flag once so every branch (array and single) treats
287
+ // tree-navigation items consistently.
288
+ const getElement = document.getElementById(elementId);
289
+ const isTreeNavigation = getElement ? getElement.hasAttribute('data-tree-navigation') : false;
290
+
282
291
  if (typeof itemsIds === 'object' && open !== null) {
283
292
  itemsIds.forEach((item) => {
284
293
  const selectItem = document.querySelector(`#${elementId} #${item}`)
285
294
  if (selectItem) {
286
- activateElement(selectItem, open)
295
+ activateElement(selectItem, open, isTreeNavigation)
287
296
  } else {
288
297
  returnMessage()
289
298
  }
@@ -291,8 +300,6 @@ export function Treeitem(aria) {
291
300
  }
292
301
 
293
302
  if (typeof itemsIds !== 'object' && open !== null) {
294
- const getElement = document.querySelector(`#${elementId}`);
295
- const isTreeNavigation = getElement.hasAttribute('data-tree-navigation');
296
303
  const selectItem = document.querySelector(`#${elementId} #${itemsIds}`)
297
304
  if (selectItem) {
298
305
  activateElement(selectItem, open, isTreeNavigation)
@@ -305,7 +312,7 @@ export function Treeitem(aria) {
305
312
  itemsIds.forEach((item) => {
306
313
  const selectItem = document.querySelector(`#${elementId} #${item[0]}`)
307
314
  if (selectItem) {
308
- activateElement(selectItem, item[1])
315
+ activateElement(selectItem, item[1], isTreeNavigation)
309
316
  } else {
310
317
  console.log('There is no item with this id in the document.');
311
318
  return null;
@@ -319,8 +326,10 @@ export function Treeitem(aria) {
319
326
  item.setAttribute('aria-expanded', 'true');
320
327
  if (treeNav) {
321
328
  const getLink = item.querySelector('a')
322
- getLink.setAttribute("aria-current", "page");
323
- getLink.innerHTML = `<strong class="font-bold">${getLink.textContent}</strong>`;
329
+ if (getLink) {
330
+ getLink.setAttribute("aria-current", "page");
331
+ getLink.innerHTML = `<strong class="font-bold">${getLink.textContent}</strong>`;
332
+ }
324
333
  }
325
334
  recursiveParent(item)
326
335
  } else {
@@ -125,6 +125,27 @@ export function utils(aria) {
125
125
  };
126
126
  }
127
127
 
128
+ var HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
129
+
130
+ // Escapes & " ' < > for safe use in HTML content and attributes. null/undefined -> ''.
131
+ export function escapeHtml(value) {
132
+ if (value === null || value === undefined) return '';
133
+ return String(value).replace(/[&<>"']/g, function (c) { return HTML_ESCAPES[c]; });
134
+ }
135
+
136
+ // Neutralizes dangerous schemes in href via an allow-list: relative URLs or http/https/mailto/tel.
137
+ // Strips C0 control chars (tab/LF/CR included) before probing, since the browser's URL parser would
138
+ // remove them and reconstruct schemes like "java\tscript:". Any other scheme returns '#'.
139
+ export function safeHref(value) {
140
+ if (value === null || value === undefined) return '';
141
+ var v = String(value);
142
+ var probe = v.replace(/[\u0000-\u001F]/g, '').trim().toLowerCase();
143
+ var scheme = probe.match(/^([a-z][a-z0-9+.-]*):/);
144
+ if (!scheme) return v;
145
+ var allowed = { http: true, https: true, mailto: true, tel: true };
146
+ return allowed[scheme[1]] === true ? v : '#';
147
+ }
148
+
128
149
  // Function to convert ISO date(s) to DD-MM-YYYY format
129
150
  export function formatDateToDDMMYYYY(isoDate) {
130
151
  if (!isoDate) return '';
package/vite.config.js CHANGED
@@ -40,6 +40,13 @@ function serveFileWithMimeType(filePath, res) {
40
40
  return true;
41
41
  }
42
42
 
43
+ // Checks that the resolved filePath stays inside baseDir (defense against path traversal)
44
+ function isPathInside(filePath, baseDir) {
45
+ if (!filePath) return false;
46
+ const resolved = path.resolve(filePath);
47
+ return resolved === baseDir || resolved.startsWith(baseDir + path.sep);
48
+ }
49
+
43
50
  // Helper functions
44
51
  function kebabCaseToPascalCase(value) {
45
52
  return value
@@ -705,10 +712,34 @@ function customNunjucksPlugin() {
705
712
  });
706
713
  });
707
714
 
715
+ // Per-asset base directories used to validate containment of served
716
+ // paths. Each middleware confines requests to its own folder (not cwd),
717
+ // so a `..` in the URL cannot escape into sensitive files.
718
+ const brandingDir = path.resolve(process.cwd(), 'branding');
719
+ const publicImagesDir = path.resolve(process.cwd(), 'public/images');
720
+ const brandingLogosDir = path.resolve(process.cwd(), 'branding/logos');
721
+ const brandingImagesDir = path.resolve(process.cwd(), 'branding/images');
722
+
708
723
  // Middleware to serve branding assets in development
709
724
  middlewares.use(async (req, res, next) => {
710
725
  if (req.url.startsWith('/branding/')) {
711
- const filePath = path.join(process.cwd(), req.url);
726
+ let decodedUrl;
727
+ try {
728
+ decodedUrl = decodeURIComponent(req.url); // neutralizes %2e%2e
729
+ } catch {
730
+ res.statusCode = 400;
731
+ res.end('Bad Request');
732
+ return;
733
+ }
734
+ // Strip the /branding/ prefix and resolve relative to brandingDir,
735
+ // so any `..` is contained to the branding folder (not cwd).
736
+ const relPath = decodedUrl.slice('/branding/'.length);
737
+ const filePath = path.resolve(brandingDir, '.' + path.sep + relPath);
738
+ if (!isPathInside(filePath, brandingDir)) {
739
+ res.statusCode = 403;
740
+ res.end('Forbidden');
741
+ return;
742
+ }
712
743
  if (serveFileWithMimeType(filePath, res)) return;
713
744
  }
714
745
  next();
@@ -733,30 +764,37 @@ function customNunjucksPlugin() {
733
764
  // Middleware to serve branding assets from /images/ in development
734
765
  middlewares.use(async (req, res, next) => {
735
766
  if (req.url.startsWith('/images/')) {
736
- let filePath = path.join(process.cwd(), 'public', req.url);
737
-
738
- if (!fs.existsSync(filePath)) {
739
- const fileName = path.basename(req.url);
740
-
741
- // Search in branding/logos (including subdirectories like eu/)
742
- const brandingLogosPath = path.join(process.cwd(), 'branding/logos');
743
- const foundInLogos = findFileRecursive(brandingLogosPath, fileName);
744
-
745
- if (foundInLogos) {
746
- filePath = foundInLogos;
747
- } else {
748
- // Search in branding/images
749
- const brandingImagesPath = path.join(process.cwd(), 'branding/images');
750
- const foundInImages = findFileRecursive(brandingImagesPath, fileName);
751
- if (foundInImages) {
752
- filePath = foundInImages;
753
- }
754
- }
767
+ let decodedUrl;
768
+ try {
769
+ decodedUrl = decodeURIComponent(req.url); // neutralizes %2e%2e
770
+ } catch {
771
+ res.statusCode = 400;
772
+ res.end('Bad Request');
773
+ return;
755
774
  }
756
-
757
- if (fs.existsSync(filePath)) {
758
- if (serveFileWithMimeType(filePath, res)) return;
775
+ // connect does not normalize req.url, so reject any literal `..`
776
+ // before touching the filesystem.
777
+ if (decodedUrl.includes('..')) {
778
+ res.statusCode = 403;
779
+ res.end('Forbidden');
780
+ return;
759
781
  }
782
+
783
+ // Primary lookup: strip the /images/ prefix, resolve under
784
+ // public/images and validate containment there.
785
+ const relPath = decodedUrl.slice('/images/'.length);
786
+ let filePath = path.resolve(publicImagesDir, '.' + path.sep + relPath);
787
+ if (isPathInside(filePath, publicImagesDir) && serveFileWithMimeType(filePath, res)) return;
788
+
789
+ // Fallbacks: search branding folders by basename, each candidate
790
+ // validated against its own base before serving.
791
+ const fileName = path.basename(decodedUrl);
792
+
793
+ const foundInLogos = findFileRecursive(brandingLogosDir, fileName);
794
+ if (foundInLogos && isPathInside(foundInLogos, brandingLogosDir) && serveFileWithMimeType(foundInLogos, res)) return;
795
+
796
+ const foundInImages = findFileRecursive(brandingImagesDir, fileName);
797
+ if (foundInImages && isPathInside(foundInImages, brandingImagesDir) && serveFileWithMimeType(foundInImages, res)) return;
760
798
  }
761
799
  next();
762
800
  });
@@ -765,10 +803,9 @@ function customNunjucksPlugin() {
765
803
  middlewares.use(async (req, res, next) => {
766
804
  if (req.url.startsWith('/branding/logos/')) {
767
805
  const fileName = path.basename(req.url);
768
- const brandingLogosPath = path.join(process.cwd(), 'branding/logos');
769
- const filePath = findFileRecursive(brandingLogosPath, fileName);
806
+ const filePath = findFileRecursive(brandingLogosDir, fileName);
770
807
 
771
- if (serveFileWithMimeType(filePath, res)) return;
808
+ if (isPathInside(filePath, brandingLogosDir) && serveFileWithMimeType(filePath, res)) return;
772
809
  }
773
810
  next();
774
811
  });
@@ -777,10 +814,9 @@ function customNunjucksPlugin() {
777
814
  middlewares.use(async (req, res, next) => {
778
815
  if (req.url.startsWith('/branding/images/')) {
779
816
  const fileName = path.basename(req.url);
780
- const brandingImagesPath = path.join(process.cwd(), 'branding/images');
781
- const filePath = findFileRecursive(brandingImagesPath, fileName);
817
+ const filePath = findFileRecursive(brandingImagesDir, fileName);
782
818
 
783
- if (serveFileWithMimeType(filePath, res)) return;
819
+ if (isPathInside(filePath, brandingImagesDir) && serveFileWithMimeType(filePath, res)) return;
784
820
  }
785
821
  next();
786
822
  });
@@ -959,7 +995,10 @@ export default defineConfig({
959
995
  watch: {
960
996
  ignored: ['!**/docs/**', '!**/src/templates/**']
961
997
  },
962
- host: '0.0.0.0',
998
+ // 0.0.0.0 exposes the dev-server to the whole network; default to localhost
999
+ // only. Set DEV_HOST=0.0.0.0 in environments like Replit, where allowedHosts
1000
+ // includes the specific *.replit.dev host needed for that public access.
1001
+ host: process.env.DEV_HOST || '127.0.0.1',
963
1002
  allowedHosts: [
964
1003
  '82c7d68c-1131-4db5-bf36-165d8f8b1d98-00-2b55tnaqky432.worf.replit.dev',
965
1004
  'localhost'