@automattic/charts 1.0.0 → 1.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.
@@ -35,4 +35,5 @@ export interface GlobalChartsContextValue {
35
35
  toggleSeriesVisibility: ( chartId: string, seriesLabel: string ) => void;
36
36
  isSeriesVisible: ( chartId: string, seriesLabel: string ) => boolean;
37
37
  getHiddenSeries: ( chartId: string ) => Set< string >;
38
+ isColorPaletteResolved: boolean;
38
39
  }
@@ -111,13 +111,15 @@ export const parseHslString = ( hslString: string ): [ number, number, number ]
111
111
  /**
112
112
  * Parse an RGB string like 'rgb(255, 0, 0)' into a hex color.
113
113
  *
114
- * @param rgbString - RGB color string
115
- * @return hex color string or null if invalid
114
+ * @deprecated Use normalizeColorToHex() instead, which handles all color formats including rgb() and rgba().
115
+ * @param rgbString - RGB color string (not RGBA)
116
+ * @return hex color string or null if invalid
116
117
  */
117
118
  export const parseRgbString = ( rgbString: string ): string | null => {
118
119
  const lower = rgbString.toLowerCase().trim();
119
120
 
120
121
  // Check prefix - only handle rgb(), not rgba()
122
+ // This is intentional - use normalizeColorToHex for rgba() support
121
123
  if ( ! lower.startsWith( 'rgb(' ) || lower.startsWith( 'rgba(' ) ) {
122
124
  return null;
123
125
  }
@@ -135,17 +137,19 @@ export const parseRgbString = ( rgbString: string ): string | null => {
135
137
 
136
138
  /**
137
139
  * Normalize any CSS color value to a hex color string.
138
- * Handles hex colors, HSL strings, RGB strings, and CSS variables.
140
+ * Handles hex, HSL, HSLA, RGB, RGBA, named CSS colors, and CSS variables.
139
141
  *
140
142
  * @param color - Any CSS color value
141
143
  * @param element - Optional DOM element for resolving CSS variables
142
144
  * @param resolveCss - Function to resolve CSS variables (injected for testability)
145
+ * @param _depth - Internal recursion depth counter to prevent infinite loops
143
146
  * @return hex color string, or the original value if conversion fails
144
147
  */
145
148
  export const normalizeColorToHex = (
146
149
  color: string,
147
150
  element?: HTMLElement | null,
148
- resolveCss?: ( value: string, el?: HTMLElement | null ) => string | null
151
+ resolveCss?: ( value: string, el?: HTMLElement | null ) => string | null,
152
+ _depth = 0
149
153
  ): string => {
150
154
  if ( ! color || typeof color !== 'string' ) {
151
155
  return '';
@@ -170,21 +174,22 @@ export const normalizeColorToHex = (
170
174
  if ( trimmed.startsWith( '--' ) || trimmed.startsWith( 'var(' ) ) {
171
175
  if ( resolveCss ) {
172
176
  const resolved = resolveCss( color, element );
173
- if ( resolved ) {
177
+ if ( resolved && resolved !== color && _depth < 10 ) {
174
178
  // Recursively normalize the resolved value
175
- return normalizeColorToHex( resolved, element, resolveCss );
179
+ return normalizeColorToHex( resolved, element, resolveCss, _depth + 1 );
176
180
  }
177
181
  }
178
182
  // Can't resolve CSS variable, return original
179
183
  return color;
180
184
  }
181
185
 
182
- // Handle HSL and RGB strings using d3-color
183
- if ( trimmed.startsWith( 'hsl(' ) || trimmed.startsWith( 'rgb(' ) ) {
184
- // Reject rgba() - we only handle rgb()
185
- if ( trimmed.startsWith( 'rgba(' ) ) {
186
- return color;
187
- }
186
+ // Handle HSL, HSLA, RGB, and RGBA strings using d3-color
187
+ if (
188
+ trimmed.startsWith( 'hsl(' ) ||
189
+ trimmed.startsWith( 'hsla(' ) ||
190
+ trimmed.startsWith( 'rgb(' ) ||
191
+ trimmed.startsWith( 'rgba(' )
192
+ ) {
188
193
  const parsed = d3Color( trimmed );
189
194
  if ( parsed ) {
190
195
  return parsed.formatHex();
@@ -192,6 +197,12 @@ export const normalizeColorToHex = (
192
197
  return color;
193
198
  }
194
199
 
200
+ // Attempt d3-color for any remaining format (e.g. named CSS colors like "steelblue")
201
+ const parsed = d3Color( trimmed );
202
+ if ( parsed ) {
203
+ return parsed.formatHex();
204
+ }
205
+
195
206
  // Unknown format, return as-is
196
207
  return color;
197
208
  };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import DOMPurify from 'dompurify';
5
+
6
+ // Enforce rel="noopener noreferrer" on links with target="_blank" to prevent tab-napping.
7
+ // Registered once at module level since DOMPurify hooks are global state.
8
+ DOMPurify.addHook( 'afterSanitizeAttributes', ( node: Element ) => {
9
+ if ( node.tagName === 'A' && node.getAttribute( 'target' ) === '_blank' ) {
10
+ node.setAttribute( 'rel', 'noopener noreferrer' );
11
+ }
12
+ } );
13
+
14
+ /**
15
+ * Sanitizes an HTML string using DOMPurify, allowing only safe formatting
16
+ * markup suitable for chart tooltip content.
17
+ *
18
+ * @param html - The HTML string to sanitize
19
+ * @return Sanitized HTML string safe for rendering
20
+ */
21
+ export function sanitizeHtml( html: string ): string {
22
+ return DOMPurify.sanitize( html, {
23
+ ALLOWED_TAGS: [
24
+ 'a',
25
+ 'b',
26
+ 'br',
27
+ 'div',
28
+ 'em',
29
+ 'i',
30
+ 'li',
31
+ 'ol',
32
+ 'p',
33
+ 'small',
34
+ 'span',
35
+ 'strong',
36
+ 'sub',
37
+ 'sup',
38
+ 'table',
39
+ 'tbody',
40
+ 'td',
41
+ 'th',
42
+ 'thead',
43
+ 'tr',
44
+ 'u',
45
+ 'ul',
46
+ ],
47
+ ALLOWED_ATTR: [ 'class', 'href', 'target', 'rel' ],
48
+ } );
49
+ }
@@ -716,6 +716,21 @@ describe( 'normalizeColorToHex', () => {
716
716
  } );
717
717
  } );
718
718
 
719
+ describe( 'HSLA strings', () => {
720
+ it( 'converts hsla(0, 100%, 50%, 1) to #ff0000', () => {
721
+ expect( normalizeColorToHex( 'hsla(0, 100%, 50%, 1)' ) ).toBe( '#ff0000' );
722
+ } );
723
+
724
+ it( 'converts hsla(120, 100%, 50%, 0.5) to #00ff00', () => {
725
+ expect( normalizeColorToHex( 'hsla(120, 100%, 50%, 0.5)' ) ).toBe( '#00ff00' );
726
+ } );
727
+
728
+ it( 'converts fully transparent hsla to #000000', () => {
729
+ // d3-color converts fully transparent colors (alpha=0) to black
730
+ expect( normalizeColorToHex( 'hsla(240, 100%, 50%, 0)' ) ).toBe( '#000000' );
731
+ } );
732
+ } );
733
+
719
734
  describe( 'RGB strings', () => {
720
735
  it( 'converts rgb(255, 0, 0) to #ff0000', () => {
721
736
  expect( normalizeColorToHex( 'rgb(255, 0, 0)' ) ).toBe( '#ff0000' );
@@ -726,6 +741,36 @@ describe( 'normalizeColorToHex', () => {
726
741
  } );
727
742
  } );
728
743
 
744
+ describe( 'RGBA strings', () => {
745
+ it( 'converts rgba(255, 0, 0, 1) to #ff0000', () => {
746
+ expect( normalizeColorToHex( 'rgba(255, 0, 0, 1)' ) ).toBe( '#ff0000' );
747
+ } );
748
+
749
+ it( 'converts rgba(0, 0, 255, 0.5) to #0000ff', () => {
750
+ // Alpha channel is stripped in hex conversion
751
+ expect( normalizeColorToHex( 'rgba(0, 0, 255, 0.5)' ) ).toBe( '#0000ff' );
752
+ } );
753
+
754
+ it( 'converts fully transparent rgba to #000000', () => {
755
+ // d3-color converts fully transparent colors (alpha=0) to black
756
+ expect( normalizeColorToHex( 'rgba(128, 128, 128, 0)' ) ).toBe( '#000000' );
757
+ } );
758
+ } );
759
+
760
+ describe( 'Named CSS colors', () => {
761
+ it( 'converts steelblue to hex', () => {
762
+ expect( normalizeColorToHex( 'steelblue' ) ).toBe( '#4682b4' );
763
+ } );
764
+
765
+ it( 'converts red to hex', () => {
766
+ expect( normalizeColorToHex( 'red' ) ).toBe( '#ff0000' );
767
+ } );
768
+
769
+ it( 'returns unknown strings as-is', () => {
770
+ expect( normalizeColorToHex( 'notacolor' ) ).toBe( 'notacolor' );
771
+ } );
772
+ } );
773
+
729
774
  describe( 'CSS variables', () => {
730
775
  it( 'returns original if no resolveCss function provided', () => {
731
776
  expect( normalizeColorToHex( '--my-color' ) ).toBe( '--my-color' );
@@ -755,6 +800,71 @@ describe( 'normalizeColorToHex', () => {
755
800
  const mockResolve = jest.fn().mockReturnValue( null );
756
801
  expect( normalizeColorToHex( '--my-color', null, mockResolve ) ).toBe( '--my-color' );
757
802
  } );
803
+
804
+ it( 'returns original when CSS variable resolves to itself', () => {
805
+ const mockResolve = jest.fn().mockImplementation( ( v: string ) => v );
806
+ expect( normalizeColorToHex( '--loop', null, mockResolve ) ).toBe( '--loop' );
807
+ } );
808
+
809
+ it( 'returns original when CSS variable resolves to empty string', () => {
810
+ const mockResolve = jest.fn().mockReturnValue( '' );
811
+ expect( normalizeColorToHex( '--empty', null, mockResolve ) ).toBe( '--empty' );
812
+ } );
813
+
814
+ it( 'does not infinite loop on indirect CSS variable cycle', () => {
815
+ const mockResolve = jest.fn().mockImplementation( ( v: string ) => {
816
+ if ( v === '--a' ) return 'var(--b)';
817
+ if ( v === 'var(--b)' ) return '--a';
818
+
819
+ return null;
820
+ } );
821
+
822
+ expect( () => normalizeColorToHex( '--a', null, mockResolve ) ).not.toThrow();
823
+ } );
824
+
825
+ it( 'resolves multi-hop CSS variable chain', () => {
826
+ const mockResolve = jest.fn().mockImplementation( ( v: string ) => {
827
+ if ( v === '--a' ) return 'var(--b)';
828
+ if ( v === 'var(--b)' ) return 'hsl(0, 100%, 50%)';
829
+
830
+ return null;
831
+ } );
832
+
833
+ expect( normalizeColorToHex( '--a', null, mockResolve ) ).toBe( '#ff0000' );
834
+ } );
835
+ } );
836
+
837
+ describe( 'Whitespace handling', () => {
838
+ it( 'trims leading and trailing spaces from hex', () => {
839
+ expect( normalizeColorToHex( ' #ff0000 ' ) ).toBe( '#ff0000' );
840
+ } );
841
+
842
+ it( 'trims spaces from HSL string', () => {
843
+ expect( normalizeColorToHex( ' hsl(0, 100%, 50%) ' ) ).toBe( '#ff0000' );
844
+ } );
845
+
846
+ it( 'trims spaces from named color', () => {
847
+ expect( normalizeColorToHex( ' red ' ) ).toBe( '#ff0000' );
848
+ } );
849
+ } );
850
+
851
+ describe( 'Case insensitivity', () => {
852
+ it( 'converts uppercase HSL', () => {
853
+ expect( normalizeColorToHex( 'HSL(0, 100%, 50%)' ) ).toBe( '#ff0000' );
854
+ } );
855
+
856
+ it( 'converts uppercase RGB', () => {
857
+ expect( normalizeColorToHex( 'RGB(255, 0, 0)' ) ).toBe( '#ff0000' );
858
+ } );
859
+
860
+ it( 'converts uppercase RGBA', () => {
861
+ expect( normalizeColorToHex( 'RGBA(0, 0, 255, 1)' ) ).toBe( '#0000ff' );
862
+ } );
863
+
864
+ it( 'handles uppercase VAR() syntax', () => {
865
+ const mockResolve = jest.fn().mockReturnValue( '#ff0000' );
866
+ expect( normalizeColorToHex( 'VAR(--my-color)', null, mockResolve ) ).toBe( '#ff0000' );
867
+ } );
758
868
  } );
759
869
 
760
870
  describe( 'Invalid inputs', () => {
@@ -0,0 +1,101 @@
1
+ import { sanitizeHtml } from '../sanitize-html';
2
+
3
+ describe( 'sanitizeHtml', () => {
4
+ test( 'preserves safe formatting tags', () => {
5
+ const input = '<b>Bold</b> <em>italic</em> <strong>strong</strong>';
6
+ expect( sanitizeHtml( input ) ).toBe( '<b>Bold</b> <em>italic</em> <strong>strong</strong>' );
7
+ } );
8
+
9
+ test( 'strips style attributes', () => {
10
+ const input = '<div style="padding: 12px;"><span style="color: red;">text</span></div>';
11
+ expect( sanitizeHtml( input ) ).toBe( '<div><span>text</span></div>' );
12
+ } );
13
+
14
+ test( 'preserves br tags', () => {
15
+ const input = 'line one<br>line two';
16
+ expect( sanitizeHtml( input ) ).toBe( 'line one<br>line two' );
17
+ } );
18
+
19
+ test( 'removes script tags', () => {
20
+ const input = '<b>Safe</b><script>alert("xss")</script>';
21
+ expect( sanitizeHtml( input ) ).toBe( '<b>Safe</b>' );
22
+ } );
23
+
24
+ test( 'removes img tags with onerror handlers', () => {
25
+ const input = '<b>Safe</b><img src=x onerror="alert(document.cookie)">';
26
+ expect( sanitizeHtml( input ) ).toBe( '<b>Safe</b>' );
27
+ } );
28
+
29
+ test( 'removes iframe tags', () => {
30
+ const input = '<div>text</div><iframe src="https://evil.com"></iframe>';
31
+ expect( sanitizeHtml( input ) ).toBe( '<div>text</div>' );
32
+ } );
33
+
34
+ test( 'removes event handler attributes from allowed tags', () => {
35
+ const input = '<div onclick="alert(1)" onmouseover="steal()">text</div>';
36
+ expect( sanitizeHtml( input ) ).toBe( '<div>text</div>' );
37
+ } );
38
+
39
+ test( 'removes javascript: URLs from href', () => {
40
+ const input = '<a href="javascript:alert(1)">click</a>';
41
+ expect( sanitizeHtml( input ) ).toBe( '<a>click</a>' );
42
+ } );
43
+
44
+ test( 'preserves safe href values', () => {
45
+ const input = '<a href="https://example.com" target="_blank" rel="noopener">link</a>';
46
+ expect( sanitizeHtml( input ) ).toBe(
47
+ '<a href="https://example.com" target="_blank" rel="noopener noreferrer">link</a>'
48
+ );
49
+ } );
50
+
51
+ test( 'removes object and embed tags', () => {
52
+ const input = '<div>safe</div><object data="x"></object><embed src="y">';
53
+ expect( sanitizeHtml( input ) ).toBe( '<div>safe</div>' );
54
+ } );
55
+
56
+ test( 'removes form tags but preserves safe child content', () => {
57
+ const input = '<form action="https://evil.com"><div>trap</div></form>';
58
+ const result = sanitizeHtml( input );
59
+ expect( result ).not.toContain( '<form' );
60
+ expect( result ).not.toContain( 'action' );
61
+ expect( result ).toContain( '<div>trap</div>' );
62
+ } );
63
+
64
+ test( 'handles complex tooltip HTML and strips style attributes', () => {
65
+ const input = `<div style="padding: 12px; font-family: sans-serif;">
66
+ <div style="font-weight: bold;">United States</div>
67
+ <div style="color: #666;">Orders: <strong>1,000</strong></div>
68
+ </div>`;
69
+ const result = sanitizeHtml( input );
70
+ expect( result ).toContain( 'United States' );
71
+ expect( result ).toContain( '<strong>1,000</strong>' );
72
+ expect( result ).not.toContain( 'style' );
73
+ } );
74
+
75
+ test( 'handles empty string', () => {
76
+ expect( sanitizeHtml( '' ) ).toBe( '' );
77
+ } );
78
+
79
+ test( 'handles plain text', () => {
80
+ expect( sanitizeHtml( 'just text' ) ).toBe( 'just text' );
81
+ } );
82
+
83
+ test( 'removes disallowed attributes like id and style', () => {
84
+ const input = '<div id="evil" style="color: red;">text</div>';
85
+ expect( sanitizeHtml( input ) ).toBe( '<div>text</div>' );
86
+ } );
87
+
88
+ test( 'enforces rel="noopener noreferrer" on target="_blank" links', () => {
89
+ const input = '<a href="https://example.com" target="_blank">link</a>';
90
+ expect( sanitizeHtml( input ) ).toBe(
91
+ '<a href="https://example.com" target="_blank" rel="noopener noreferrer">link</a>'
92
+ );
93
+ } );
94
+
95
+ test( 'overrides insufficient rel on target="_blank" links', () => {
96
+ const input = '<a href="https://example.com" target="_blank" rel="noopener">link</a>';
97
+ expect( sanitizeHtml( input ) ).toBe(
98
+ '<a href="https://example.com" target="_blank" rel="noopener noreferrer">link</a>'
99
+ );
100
+ } );
101
+ } );