desy-html 17.0.0 → 17.0.2

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.
@@ -58,7 +58,7 @@
58
58
  { "text": "Valencia" }
59
59
  ]
60
60
  },
61
- "visualDescription": "Combobox con label negro encima. Campo rectangular con borde gris, fondo blanco e icono de chevron a la derecha, con texto de ayuda gris para filtrar. Al desplegarse muestra la lista de opciones. Puede tener varias opciones seleccionadas. Las seleccionadas aparecen en negrita con un checkmark a la izquierda. El listbox permanece abierto al elegir varias."
61
+ "visualDescription": "Combobox con label negro encima. Campo rectangular con borde gris, fondo blanco e icono de chevron a la derecha, con texto de ayuda gris para filtrar. Al desplegarse muestra la lista de opciones. Puede tener varias opciones seleccionadas. Las seleccionadas aparecen en negrita con un checkmark a la izquierda. El combobox permanece abierto al elegir varias."
62
62
  },
63
63
  {
64
64
  "name": "con estado activo",
@@ -309,29 +309,25 @@
309
309
  },
310
310
  {
311
311
  "name": "peque",
312
- "description": "Label solo visible para lectores de pantalla.",
313
312
  "data": {
314
- "id": "small",
315
- "name": "provincia-small",
313
+ "id": "combobox-sm",
314
+ "name": "combobox-sm",
315
+ "classes": "c-combobox--sm",
316
316
  "label": {
317
- "text": "Provincia",
318
- "classes": "sr-only"
317
+ "text": "Esto es un label",
318
+ "classes": "text-sm"
319
319
  },
320
- "classes": "px-2 py-1 text-sm",
321
320
  "items": [
322
- { "text": "Huesca" },
323
- { "text": "Zaragoza" },
324
- { "text": "Teruel" },
325
- { "text": "Barcelona" },
326
- { "text": "Madrid" },
327
- { "text": "Valencia" }
321
+ { "text": "Opción 1" },
322
+ { "text": "Opción 2", "selected": true },
323
+ { "text": "Opción 3" }
328
324
  ]
329
325
  },
330
- "visualDescription": "Combobox con label negro en tamaño estándar posicionado arriba del campo. Dropdown rectangular compacto con borde gris de 1px, fondo blanco, esquinas redondeadas y flecha desplegable gris a la derecha. Altura reducida del campo de entrada en comparación con dimensiones estándar.",
326
+ "visualDescription": "Combobox pequeño con label visible en tamaño reducido. Campo rectangular compacto con borde gris, fondo blanco, texto pequeño y flecha desplegable alineada a la derecha.",
331
327
  "_generated": {
332
- "hash": "9ceb87c4fb1288fdef9c191d3ea711dc",
328
+ "hash": "f4c92a618d70e35b9a14cc782ebf063d",
333
329
  "version": "1.0.0",
334
- "timestamp": "2026-04-20T17:48:28.284Z"
330
+ "timestamp": "2026-07-22T00:00:00.000Z"
335
331
  }
336
332
  },
337
333
  {
@@ -436,5 +432,71 @@
436
432
  "version": "1.0.0",
437
433
  "timestamp": "2026-04-20T17:48:37.642Z"
438
434
  }
435
+ },
436
+ {
437
+ "name": "ancho completo",
438
+ "description": "Utiliza <code>classes: \"w-full\"</code> para que el combobox ocupe todo el ancho disponible.",
439
+ "data": {
440
+ "id": "full-width",
441
+ "name": "provincia-full",
442
+ "label": {
443
+ "text": "Provincia"
444
+ },
445
+ "classes": "w-full",
446
+ "autocomplete": "both",
447
+ "items": [
448
+ {
449
+ "text": "Huesca"
450
+ },
451
+ {
452
+ "text": "Zaragoza"
453
+ },
454
+ {
455
+ "text": "Teruel"
456
+ },
457
+ {
458
+ "text": "Barcelona"
459
+ },
460
+ {
461
+ "text": "Madrid"
462
+ },
463
+ {
464
+ "text": "Valencia"
465
+ }
466
+ ]
467
+ },
468
+ "visualDescription": "Combobox que ocupa todo el ancho del contenedor. Label gris oscuro pequeño posicionado arriba. Campo rectangular a ancho completo con borde gris claro de 1px, fondo blanco, y ícono de chevron gris apuntando hacia abajo pegado al borde derecho del input."
469
+ },
470
+ {
471
+ "name": "con clases de form-group opcionales",
472
+ "description": "Label en línea + Combobox flexible y mensaje de error",
473
+ "data": {
474
+ "id": "combobox-form-group-opcionales",
475
+ "name": "combobox-form-group-opcionales",
476
+ "classes": "w-full",
477
+ "classesContainer": "lg:flex-1",
478
+ "label": {
479
+ "text": "Label en línea:",
480
+ "classes": "lg:py-sm lg:mt-sm"
481
+ },
482
+ "formGroup": {
483
+ "classes": "lg:flex lg:flex-wrap lg:items-center lg:gap-x-base"
484
+ },
485
+ "errorMessage": {
486
+ "text": "Esto es un mensaje de error",
487
+ "classes": "order-1 w-full pt-sm"
488
+ },
489
+ "items": [
490
+ { "text": "Opción 1" },
491
+ { "text": "Opción 2", "selected": true },
492
+ { "text": "Opción 3" }
493
+ ]
494
+ },
495
+ "visualDescription": "Combobox con label en línea horizontal en negro a la izquierda del campo en pantallas grandes (label arriba en móvil). Contenedor con borde rojo de 2px por el mensaje de error, fondo blanco, y valor seleccionado visible. Mensaje de error en rojo debajo del campo indicando validación fallida. Botón toggle con chevron gris a la derecha del campo.",
496
+ "_generated": {
497
+ "hash": "f27e5d917921b62038775f3ad51fc257",
498
+ "version": "1.0.0",
499
+ "timestamp": "2026-07-22T00:00:00.000Z"
500
+ }
439
501
  }
440
502
  ] %}
@@ -6,6 +6,10 @@
6
6
  @apply relative;
7
7
  @apply inline-flex;
8
8
  @apply items-center;
9
+
10
+ &:has(> .w-full) {
11
+ @apply w-full;
12
+ }
9
13
  }
10
14
 
11
15
  .c-combobox__toggle {
@@ -89,4 +93,17 @@
89
93
  @apply text-neutral-dark;
90
94
  @apply italic;
91
95
  }
96
+
97
+ .c-combobox--base {
98
+ @apply pr-10;
99
+ @apply pl-3;
100
+ @apply py-2;
101
+ @apply text-base;
102
+ }
103
+
104
+ .c-combobox--sm {
105
+ @apply pl-2;
106
+ @apply py-1;
107
+ @apply text-sm;
108
+ }
92
109
  }
@@ -58,7 +58,7 @@
58
58
  {% endif %}
59
59
  <div class="c-combobox relative {%- if params.classesContainer %} {{ params.classesContainer }}{% endif %}">
60
60
  <div class="c-combobox__input-wrapper">
61
- <input class="c-input c-combobox__input block mt-sm {% if params.errorMessage %}border-alert-base ring-2 ring-alert-base{% else %}border-black{% endif %} rounded-sm font-semibold placeholder-neutral-dark focus:border-black focus:shadow-outline-focus-input focus:ring-4 focus:ring-warning-base disabled:bg-neutral-light disabled:border-neutral-base {%- if params.classes %} {{ params.classes }}{% endif %}" id="{{ params.id }}" type="text" role="combobox" autocomplete="off" aria-autocomplete="{{ autocompleteMode }}" aria-expanded="false" aria-haspopup="listbox" aria-controls="{{ listboxId }}"
61
+ <input class="c-input c-combobox__input block mt-sm pr-10 {% if params.errorMessage %}border-alert-base ring-2 ring-alert-base{% else %}border-black{% endif %} rounded-sm font-semibold placeholder-neutral-dark focus:border-black focus:shadow-outline-focus-input focus:ring-4 focus:ring-warning-base disabled:bg-neutral-light disabled:border-neutral-base {%- if params.classes %} {{ params.classes }}{% endif %}" id="{{ params.id }}" type="text" role="combobox" autocomplete="off" aria-autocomplete="{{ autocompleteMode }}" aria-expanded="false" aria-haspopup="listbox" aria-controls="{{ listboxId }}"
62
62
  {%- if inputValue %} value="{{ inputValue }}"{% endif %}
63
63
  {%- if params.placeholder %} placeholder="{{ params.placeholder }}"{% endif %}
64
64
  {%- if describedBy %} aria-describedby="{{ describedBy }}"{% endif %}
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'