@automattic/charts 0.59.0 → 1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "0.59.0",
3
+ "version": "1.0.1",
4
4
  "description": "Display charts within Automattic products.",
5
5
  "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
6
6
  "bugs": {
@@ -63,7 +63,7 @@
63
63
  "typecheck": "tsgo --noEmit"
64
64
  },
65
65
  "dependencies": {
66
- "@automattic/number-formatters": "^1.1.2",
66
+ "@automattic/number-formatters": "^1.1.3",
67
67
  "@babel/runtime": "7.28.6",
68
68
  "@react-spring/web": "9.7.5",
69
69
  "@visx/annotation": "^3.12.0",
@@ -88,6 +88,7 @@
88
88
  "clsx": "2.1.1",
89
89
  "date-fns": "^4.1.0",
90
90
  "deepmerge": "4.3.1",
91
+ "dompurify": "^3.3.3",
91
92
  "fast-deep-equal": "3.1.3",
92
93
  "gridicons": "3.4.2",
93
94
  "react-google-charts": "^5.2.1",
@@ -111,13 +112,13 @@
111
112
  "@visx/glyph": "3.12.0",
112
113
  "@wordpress/components": "32.4.0",
113
114
  "@wordpress/element": "6.42.0",
114
- "babel-jest": "30.2.0",
115
+ "babel-jest": "30.3.0",
115
116
  "babel-plugin-react-remove-properties": "^0.3.1",
116
117
  "esbuild": "0.25.9",
117
118
  "esbuild-plugin-babel": "^0.2.3",
118
119
  "esbuild-sass-plugin": "^3.1.0",
119
120
  "identity-obj-proxy": "^3.0.0",
120
- "jest": "30.2.0",
121
+ "jest": "30.3.0",
121
122
  "jest-extended": "7.0.0",
122
123
  "postcss": "8.5.6",
123
124
  "postcss-modules": "6.0.1",
@@ -133,6 +134,6 @@
133
134
  "react-dom": "^17.0.0 || ^18.0.0"
134
135
  },
135
136
  "engines": {
136
- "node": ">=20.10.0"
137
+ "node": ">=20.11.0"
137
138
  }
138
139
  }
@@ -11,6 +11,7 @@ import { Chart, type GoogleChartOptions } from 'react-google-charts';
11
11
  import { GlobalChartsContext, GlobalChartsProvider, useGlobalChartsContext } from '../../providers';
12
12
  import { lightenHexColor, normalizeColorToHex } from '../../utils/color-utils';
13
13
  import { resolveCssVariable } from '../../utils/resolve-css-var';
14
+ import { sanitizeHtml } from '../../utils/sanitize-html';
14
15
  import { withResponsive } from '../private/with-responsive';
15
16
  import styles from './geo-chart.module.scss';
16
17
  import { GeoChartProps } from './types';
@@ -74,24 +75,50 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
74
75
  const defaultFillColorHex =
75
76
  normalizeColorToHex( featureFillColor, null, resolveCssVariable ) || DEFAULT_FEATURE_FILL_COLOR;
76
77
 
77
- // Check if data has HTML tooltips (column with role: 'tooltip' and p.html: true)
78
- const hasHtmlTooltips = useMemo(
79
- () =>
80
- data.length > 0 &&
81
- data[ 0 ].some(
82
- col =>
83
- typeof col === 'object' &&
84
- col !== null &&
85
- 'role' in col &&
86
- col.role === 'tooltip' &&
87
- 'p' in col &&
88
- typeof col.p === 'object' &&
89
- col.p !== null &&
90
- 'html' in col.p &&
91
- col.p.html === true
92
- ),
93
- [ data ]
94
- );
78
+ // Identify HTML tooltip column indices and sanitize their content to prevent XSS.
79
+ const sanitizedData = useMemo( () => {
80
+ if ( data.length === 0 ) {
81
+ return { data, hasHtmlTooltips: false };
82
+ }
83
+
84
+ const htmlTooltipIndices: number[] = [];
85
+ for ( let i = 0; i < data[ 0 ].length; i++ ) {
86
+ const col = data[ 0 ][ i ];
87
+ if (
88
+ typeof col === 'object' &&
89
+ col !== null &&
90
+ 'role' in col &&
91
+ col.role === 'tooltip' &&
92
+ 'p' in col &&
93
+ typeof col.p === 'object' &&
94
+ col.p !== null &&
95
+ 'html' in col.p &&
96
+ col.p.html === true
97
+ ) {
98
+ htmlTooltipIndices.push( i );
99
+ }
100
+ }
101
+
102
+ if ( htmlTooltipIndices.length === 0 ) {
103
+ return { data, hasHtmlTooltips: false };
104
+ }
105
+
106
+ // Sanitize HTML content in tooltip columns for data rows (skip header row)
107
+ const sanitizedRows = data.slice( 1 ).map( row => {
108
+ const newRow = [ ...row ];
109
+ for ( const colIndex of htmlTooltipIndices ) {
110
+ if ( typeof newRow[ colIndex ] === 'string' ) {
111
+ newRow[ colIndex ] = sanitizeHtml( newRow[ colIndex ] as string );
112
+ }
113
+ }
114
+ return newRow;
115
+ } );
116
+
117
+ return {
118
+ data: [ data[ 0 ], ...sanitizedRows ] as typeof data,
119
+ hasHtmlTooltips: true,
120
+ };
121
+ }, [ data ] );
95
122
 
96
123
  const options: GoogleChartOptions = useMemo(
97
124
  () => ( {
@@ -101,7 +128,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
101
128
  backgroundColor: backgroundColorHex,
102
129
  datalessRegionColor: defaultFillColorHex,
103
130
  defaultColor: defaultFillColorHex,
104
- tooltip: { trigger: 'focus', isHtml: hasHtmlTooltips },
131
+ tooltip: { trigger: 'focus', isHtml: sanitizedData.hasHtmlTooltips },
105
132
  legend: 'none',
106
133
  keepAspectRatio: true,
107
134
  } ),
@@ -112,7 +139,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
112
139
  fullColorHex,
113
140
  backgroundColorHex,
114
141
  defaultFillColorHex,
115
- hasHtmlTooltips,
142
+ sanitizedData.hasHtmlTooltips,
116
143
  ]
117
144
  );
118
145
 
@@ -126,7 +153,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
126
153
  chartType="GeoChart"
127
154
  width={ width }
128
155
  height={ height }
129
- data={ data }
156
+ data={ sanitizedData.data }
130
157
  options={ options }
131
158
  loader={ loadingPlaceholder }
132
159
  />
@@ -67,24 +67,62 @@ describe( 'GeoChart', () => {
67
67
  } );
68
68
 
69
69
  describe( 'Data Handling', () => {
70
- test( 'passes data directly to Google Charts without modification', () => {
71
- // Test with complex data including plain values, formatted values, and tooltip columns
72
- const testData: [
73
- ( string | object )[],
74
- ...[ string, number | { v: number; f: string }, string ][],
75
- ] = [
76
- [ 'Country', 'Revenue', { type: 'string', role: 'tooltip', p: { html: true } } ],
77
- [ 'US', { v: 1234567, f: '$1.23M' }, '<b>United States</b>' ],
78
- [ 'CA', 543210, '<b>Canada</b>' ],
70
+ test( 'passes non-tooltip data without modification', () => {
71
+ const testData: [ string[], ...[ string, number ][] ] = [
72
+ [ 'Country', 'Revenue' ],
73
+ [ 'US', 1234567 ],
74
+ [ 'CA', 543210 ],
79
75
  ];
80
76
  renderWithTheme( { data: testData } );
81
77
 
82
78
  const chartData = screen.getByTestId( 'chart-data' );
83
79
  const data = JSON.parse( chartData.textContent || '[]' );
84
80
 
85
- // Data should be passed through exactly as provided
86
81
  expect( data ).toEqual( testData );
87
82
  } );
83
+
84
+ test( 'sanitizes HTML tooltip content to prevent XSS', () => {
85
+ const testData: [ ( string | object )[], ...[ string, number, string ][] ] = [
86
+ [ 'Country', 'Value', { type: 'string', role: 'tooltip', p: { html: true } } ],
87
+ [ 'US', 100, '<b>United States</b><script>alert("xss")</script>' ],
88
+ [ 'CA', 50, '<b>Canada</b><img src=x onerror="alert(1)">' ],
89
+ ];
90
+ renderWithTheme( { data: testData } );
91
+
92
+ const chartData = screen.getByTestId( 'chart-data' );
93
+ const data = JSON.parse( chartData.textContent || '[]' );
94
+
95
+ // Script tags and img (not in allowlist) should be stripped
96
+ expect( data[ 1 ][ 2 ] ).toBe( '<b>United States</b>' );
97
+ expect( data[ 2 ][ 2 ] ).toBe( '<b>Canada</b>' );
98
+ } );
99
+
100
+ test( 'handles header-only data with HTML tooltip column and no data rows', () => {
101
+ const testData: [ ( string | object )[] ] = [
102
+ [ 'Country', 'Value', { type: 'string', role: 'tooltip', p: { html: true } } ],
103
+ ];
104
+ renderWithTheme( { data: testData } );
105
+
106
+ const chartData = screen.getByTestId( 'chart-data' );
107
+ const data = JSON.parse( chartData.textContent || '[]' );
108
+
109
+ // Header row should be preserved as-is
110
+ expect( data ).toHaveLength( 1 );
111
+ expect( data[ 0 ][ 0 ] ).toBe( 'Country' );
112
+ } );
113
+
114
+ test( 'preserves safe HTML in tooltip content', () => {
115
+ const testData: [ ( string | object )[], ...[ string, number, string ][] ] = [
116
+ [ 'Country', 'Value', { type: 'string', role: 'tooltip', p: { html: true } } ],
117
+ [ 'US', 100, '<b>United States</b><br>100 orders' ],
118
+ ];
119
+ renderWithTheme( { data: testData } );
120
+
121
+ const chartData = screen.getByTestId( 'chart-data' );
122
+ const data = JSON.parse( chartData.textContent || '[]' );
123
+
124
+ expect( data[ 1 ][ 2 ] ).toBe( '<b>United States</b><br>100 orders' );
125
+ } );
88
126
  } );
89
127
 
90
128
  describe( 'Chart Options', () => {
@@ -4,4 +4,12 @@
4
4
  flex: 1;
5
5
  min-height: 0;
6
6
  min-width: 0;
7
+
8
+ // The svg displaying inline includes a few px for descenders, which
9
+ // confuses some browser environments (e.g. headless Chrome) into
10
+ // constantly growing the container. Setting it to display as block avoids
11
+ // that.
12
+ svg {
13
+ display: block;
14
+ }
7
15
  }
@@ -86,25 +86,19 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
86
86
  if ( Array.isArray( colors ) ) {
87
87
  for ( const color of colors ) {
88
88
  if ( color && typeof color === 'string' ) {
89
- let colorValue = color;
90
-
91
- // Handle CSS custom properties - resolve them to actual values
92
- // Supports both '--var-name' and 'var(--var-name)' formats
93
- // Use wrapper element to resolve scoped CSS variables
94
- if ( color.startsWith( '--' ) || color.startsWith( 'var(' ) ) {
95
- const resolved = resolveCssVariable( color, wrapperRef.current );
96
-
97
- if ( resolved === null || resolved === '' ) {
98
- continue;
99
- }
100
-
101
- colorValue = resolved;
102
- }
103
-
104
- // Process hex colors
105
- if ( colorValue.startsWith( '#' ) ) {
106
- resolvedColors.push( colorValue );
107
- const hslColor = d3Hsl( colorValue );
89
+ // Normalize color to hex format, handling CSS variables, RGB, HSL, etc.
90
+ // This uses normalizeColorToHex which resolves CSS variables and converts
91
+ // rgb(), rgba(), hsl() formats to hex
92
+ const normalizedColor = normalizeColorToHex(
93
+ color,
94
+ wrapperRef.current,
95
+ resolveCssVariable
96
+ );
97
+
98
+ // Only process valid hex colors
99
+ if ( normalizedColor.startsWith( '#' ) ) {
100
+ resolvedColors.push( normalizedColor );
101
+ const hslColor = d3Hsl( normalizedColor );
108
102
  // d3Hsl returns NaN values for invalid colors
109
103
  if ( ! isNaN( hslColor.h ) ) {
110
104
  const hslTuple: [ number, number, number ] = [
@@ -1907,7 +1907,7 @@ describe( 'ChartContext', () => {
1907
1907
  };
1908
1908
 
1909
1909
  const cssVarTheme: ChartTheme = {
1910
- colors: [ '--rgb-color', '#ff0000' ],
1910
+ colors: [ '--rgb-color', '#00ff00' ],
1911
1911
  } as ChartTheme;
1912
1912
 
1913
1913
  render(
@@ -1916,7 +1916,7 @@ describe( 'ChartContext', () => {
1916
1916
  </GlobalChartsProvider>
1917
1917
  );
1918
1918
 
1919
- // Non-hex colors are currently skipped, should use second color
1919
+ // RGB colors should now be converted to hex and used
1920
1920
  const color = contextValue.getElementStyles( {
1921
1921
  data: undefined,
1922
1922
  index: 0,
@@ -1924,6 +1924,127 @@ describe( 'ChartContext', () => {
1924
1924
 
1925
1925
  expect( color ).toBe( '#ff0000' );
1926
1926
  } );
1927
+
1928
+ it( 'handles CSS variables resolving to HSL colors', () => {
1929
+ window.getComputedStyle = jest.fn( () => ( {
1930
+ getPropertyValue: ( prop: string ) => {
1931
+ if ( prop === '--hsl-color' ) {
1932
+ return 'hsl(120, 100%, 50%)'; // HSL format (green)
1933
+ }
1934
+ return '';
1935
+ },
1936
+ } ) ) as unknown as typeof window.getComputedStyle;
1937
+
1938
+ let contextValue: GlobalChartsContextValue;
1939
+
1940
+ const TestComponent = () => {
1941
+ contextValue = useGlobalChartsContext();
1942
+ return <div>Test</div>;
1943
+ };
1944
+
1945
+ const cssVarTheme: ChartTheme = {
1946
+ colors: [ '--hsl-color', '#ff0000' ],
1947
+ } as ChartTheme;
1948
+
1949
+ render(
1950
+ <GlobalChartsProvider theme={ cssVarTheme }>
1951
+ <TestComponent />
1952
+ </GlobalChartsProvider>
1953
+ );
1954
+
1955
+ // HSL colors should be converted to hex and used
1956
+ const color = contextValue.getElementStyles( {
1957
+ data: undefined,
1958
+ index: 0,
1959
+ } ).color;
1960
+
1961
+ expect( color ).toBe( '#00ff00' );
1962
+ } );
1963
+
1964
+ it( 'handles CSS variables resolving to RGBA colors', () => {
1965
+ window.getComputedStyle = jest.fn( () => ( {
1966
+ getPropertyValue: ( prop: string ) => {
1967
+ if ( prop === '--rgba-color' ) {
1968
+ return 'rgba(0, 0, 255, 0.5)'; // RGBA format (blue with transparency)
1969
+ }
1970
+ return '';
1971
+ },
1972
+ } ) ) as unknown as typeof window.getComputedStyle;
1973
+
1974
+ let contextValue: GlobalChartsContextValue;
1975
+
1976
+ const TestComponent = () => {
1977
+ contextValue = useGlobalChartsContext();
1978
+ return <div>Test</div>;
1979
+ };
1980
+
1981
+ const cssVarTheme: ChartTheme = {
1982
+ colors: [ '--rgba-color', '#ff0000' ],
1983
+ } as ChartTheme;
1984
+
1985
+ render(
1986
+ <GlobalChartsProvider theme={ cssVarTheme }>
1987
+ <TestComponent />
1988
+ </GlobalChartsProvider>
1989
+ );
1990
+
1991
+ // RGBA colors are converted to hex (alpha is stripped)
1992
+ const color = contextValue.getElementStyles( {
1993
+ data: undefined,
1994
+ index: 0,
1995
+ } ).color;
1996
+
1997
+ expect( color ).toBe( '#0000ff' );
1998
+ } );
1999
+
2000
+ it( 'handles mix of RGB, HSL, and hex in theme colors', () => {
2001
+ window.getComputedStyle = jest.fn( () => ( {
2002
+ getPropertyValue: ( prop: string ) => {
2003
+ if ( prop === '--rgb-red' ) {
2004
+ return 'rgb(255, 0, 0)';
2005
+ }
2006
+ if ( prop === '--hsl-green' ) {
2007
+ return 'hsl(120, 100%, 50%)';
2008
+ }
2009
+ return '';
2010
+ },
2011
+ } ) ) as unknown as typeof window.getComputedStyle;
2012
+
2013
+ let contextValue: GlobalChartsContextValue;
2014
+
2015
+ const TestComponent = () => {
2016
+ contextValue = useGlobalChartsContext();
2017
+ return <div>Test</div>;
2018
+ };
2019
+
2020
+ const cssVarTheme: ChartTheme = {
2021
+ colors: [ '--rgb-red', '--hsl-green', '#0000ff' ],
2022
+ } as ChartTheme;
2023
+
2024
+ render(
2025
+ <GlobalChartsProvider theme={ cssVarTheme }>
2026
+ <TestComponent />
2027
+ </GlobalChartsProvider>
2028
+ );
2029
+
2030
+ // All color formats should be properly converted
2031
+ const color1 = contextValue.getElementStyles( {
2032
+ data: undefined,
2033
+ index: 0,
2034
+ } ).color;
2035
+ const color2 = contextValue.getElementStyles( {
2036
+ data: undefined,
2037
+ index: 1,
2038
+ } ).color;
2039
+ const color3 = contextValue.getElementStyles( {
2040
+ data: undefined,
2041
+ index: 2,
2042
+ } ).color;
2043
+
2044
+ expect( color1 ).toBe( '#ff0000' ); // RGB red
2045
+ expect( color2 ).toBe( '#00ff00' ); // HSL green
2046
+ expect( color3 ).toBe( '#0000ff' ); // Hex blue
2047
+ } );
1927
2048
  } );
1928
2049
 
1929
2050
  describe( 'Error Handling', () => {
@@ -2058,7 +2179,7 @@ describe( 'ChartContext', () => {
2058
2179
  } ).color;
2059
2180
 
2060
2181
  expect( color1 ).toBe( '#ff0000' );
2061
- expect( color2 ).toBe( '#bad' ); // Invalid color is still in palette
2182
+ expect( color2 ).toBe( '#bbaadd' ); // #bad is expanded to #bbaadd by normalizeColorToHex
2062
2183
  expect( color3 ).toBe( '#0000ff' );
2063
2184
  } );
2064
2185
  } );
@@ -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
+ }