@transferwise/components 46.155.1 → 46.156.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/build/Markup/Markup.js +138 -0
  2. package/build/Markup/Markup.js.map +1 -0
  3. package/build/Markup/Markup.mjs +133 -0
  4. package/build/Markup/Markup.mjs.map +1 -0
  5. package/build/Markup/constants.js +21 -0
  6. package/build/Markup/constants.js.map +1 -0
  7. package/build/Markup/constants.mjs +15 -0
  8. package/build/Markup/constants.mjs.map +1 -0
  9. package/build/Markup/utils/parseMarkup.js +263 -0
  10. package/build/Markup/utils/parseMarkup.js.map +1 -0
  11. package/build/Markup/utils/parseMarkup.mjs +258 -0
  12. package/build/Markup/utils/parseMarkup.mjs.map +1 -0
  13. package/build/Markup/utils/sanitise.js +111 -0
  14. package/build/Markup/utils/sanitise.js.map +1 -0
  15. package/build/Markup/utils/sanitise.mjs +108 -0
  16. package/build/Markup/utils/sanitise.mjs.map +1 -0
  17. package/build/i18n/hu.json +1 -1
  18. package/build/i18n/hu.json.js +1 -1
  19. package/build/i18n/hu.json.mjs +1 -1
  20. package/build/index.js +2 -0
  21. package/build/index.js.map +1 -1
  22. package/build/index.mjs +1 -0
  23. package/build/index.mjs.map +1 -1
  24. package/build/main.css +35 -0
  25. package/build/styles/Markup/Markup.css +28 -0
  26. package/build/styles/Markup/_stories/Vulnerability/Vulnerability.css +69 -0
  27. package/build/styles/main.css +35 -0
  28. package/build/types/Body/Body.d.ts +2 -2
  29. package/build/types/IconButton/IconButton.d.ts +1 -1
  30. package/build/types/Markup/Markup.d.ts +18 -0
  31. package/build/types/Markup/Markup.d.ts.map +1 -0
  32. package/build/types/Markup/Markup.types.d.ts +77 -0
  33. package/build/types/Markup/Markup.types.d.ts.map +1 -0
  34. package/build/types/Markup/_stories/Vulnerability/Vulnerability.d.ts +12 -0
  35. package/build/types/Markup/_stories/Vulnerability/Vulnerability.d.ts.map +1 -0
  36. package/build/types/Markup/constants.d.ts +6 -0
  37. package/build/types/Markup/constants.d.ts.map +1 -0
  38. package/build/types/Markup/index.d.ts +3 -0
  39. package/build/types/Markup/index.d.ts.map +1 -0
  40. package/build/types/Markup/utils/parseMarkup.d.ts +8 -0
  41. package/build/types/Markup/utils/parseMarkup.d.ts.map +1 -0
  42. package/build/types/Markup/utils/sanitise.d.ts +4 -0
  43. package/build/types/Markup/utils/sanitise.d.ts.map +1 -0
  44. package/build/types/MoneyInput/MoneyInput.d.ts +1 -1
  45. package/build/types/MoneyInput/MoneyInput.d.ts.map +1 -1
  46. package/build/types/Title/Title.d.ts +2 -2
  47. package/build/types/Upload/Steps/UploadImageStep/UploadImageStep.d.ts +1 -1
  48. package/build/types/UploadInput/UploadItem/UploadItemLink.d.ts +1 -1
  49. package/build/types/index.d.ts +2 -0
  50. package/build/types/index.d.ts.map +1 -1
  51. package/package.json +1 -1
  52. package/src/Markup/Markup.css +28 -0
  53. package/src/Markup/Markup.injection.test.tsx +163 -0
  54. package/src/Markup/Markup.less +29 -0
  55. package/src/Markup/Markup.test.tsx +502 -0
  56. package/src/Markup/Markup.tsx +175 -0
  57. package/src/Markup/Markup.types.ts +62 -0
  58. package/src/Markup/_stories/Markup.docs.mdx +75 -0
  59. package/src/Markup/_stories/Markup.security.docs.mdx +343 -0
  60. package/src/Markup/_stories/Markup.story.tsx +266 -0
  61. package/src/Markup/_stories/Vulnerability/Vulnerability.css +69 -0
  62. package/src/Markup/_stories/Vulnerability/Vulnerability.tsx +30 -0
  63. package/src/Markup/constants.ts +23 -0
  64. package/src/Markup/index.ts +2 -0
  65. package/src/Markup/utils/parseMarkup.test.ts +706 -0
  66. package/src/Markup/utils/parseMarkup.ts +265 -0
  67. package/src/Markup/utils/sanitise.test.ts +499 -0
  68. package/src/Markup/utils/sanitise.ts +129 -0
  69. package/src/i18n/hu.json +1 -1
  70. package/src/index.ts +2 -0
  71. package/src/main.css +35 -0
  72. package/src/main.less +1 -0
@@ -0,0 +1,502 @@
1
+ import React from 'react';
2
+
3
+ import { render, screen, userEvent } from '../test-utils';
4
+
5
+ import { Markup } from './Markup';
6
+
7
+ describe('Markup', () => {
8
+ it('renders plain text', () => {
9
+ render(<Markup>hello world</Markup>);
10
+ expect(screen.getByText('hello world')).toBeInTheDocument();
11
+ });
12
+
13
+ it('returns null for empty children', () => {
14
+ const { container } = render(<Markup />);
15
+ expect(container).toBeEmptyDOMElement();
16
+ });
17
+
18
+ describe('wrapper element', () => {
19
+ it('renders in a span wrapper for inline content', () => {
20
+ render(<Markup>content</Markup>);
21
+ const element = screen.getByText('content');
22
+ expect(element.tagName).toBe('SPAN');
23
+ });
24
+
25
+ it('renders in a div wrapper when paragraph nodes are present', () => {
26
+ const { container } = render(<Markup>{'<p>block content</p>'}</Markup>);
27
+ const wrapper = container.firstChild as HTMLElement;
28
+ expect(wrapper.tagName).toBe('DIV');
29
+ });
30
+
31
+ it('renders in a div wrapper when nested paragraph nodes are present', () => {
32
+ const { container } = render(
33
+ <Markup>{'<important><p>nested paragraph</p></important>'}</Markup>,
34
+ );
35
+ const wrapper = container.firstChild as HTMLElement;
36
+ expect(wrapper.tagName).toBe('DIV');
37
+ });
38
+
39
+ it('uses span when as="span" even with paragraphs', () => {
40
+ const { container } = render(<Markup as="span">{'<p>forced span</p>'}</Markup>);
41
+ const wrapper = container.firstChild as HTMLElement;
42
+ expect(wrapper.tagName).toBe('SPAN');
43
+ });
44
+
45
+ it('uses div when as="div" even without paragraphs', () => {
46
+ const { container } = render(<Markup as="div">inline but div</Markup>);
47
+ const wrapper = container.firstChild as HTMLElement;
48
+ expect(wrapper.tagName).toBe('DIV');
49
+ });
50
+
51
+ it('accepts any HTML element via as prop', () => {
52
+ const { container } = render(<Markup as="section">{'<p>in a section</p>'}</Markup>);
53
+ const wrapper = container.firstChild as HTMLElement;
54
+ expect(wrapper.tagName).toBe('SECTION');
55
+ });
56
+
57
+ it('accepts a React component via as prop', () => {
58
+ const Card = ({
59
+ className,
60
+ children,
61
+ }: {
62
+ className?: string;
63
+ children?: React.ReactNode;
64
+ }) => (
65
+ <article data-testid="card" className={className}>
66
+ {children}
67
+ </article>
68
+ );
69
+ const { container } = render(
70
+ <Markup as={Card} className="custom">
71
+ {'<important>inside component</important>'}
72
+ </Markup>,
73
+ );
74
+ expect(screen.getByTestId('card')).toHaveClass('custom');
75
+ expect(container.querySelector('em')).toHaveTextContent('inside component');
76
+ });
77
+
78
+ it('passes data-testid to the wrapper element', () => {
79
+ render(<Markup data-testid="markup-wrapper">content</Markup>);
80
+ expect(screen.getByTestId('markup-wrapper')).toBeInTheDocument();
81
+ });
82
+
83
+ it('applies className to wrapper regardless of element type', () => {
84
+ render(<Markup className="custom">content</Markup>);
85
+ expect(screen.getByText('content')).toHaveClass('custom');
86
+ });
87
+
88
+ it('applies className to div wrapper with paragraphs', () => {
89
+ const { container } = render(<Markup className="block-wrapper">{'<p>paragraph</p>'}</Markup>);
90
+ const wrapper = container.firstChild as HTMLElement;
91
+ expect(wrapper).toHaveClass('block-wrapper');
92
+ expect(wrapper.tagName).toBe('DIV');
93
+ });
94
+ });
95
+
96
+ describe('emphasis', () => {
97
+ it('renders <important> with correct class', () => {
98
+ render(<Markup>{'<important>critical</important>'}</Markup>);
99
+ const element = screen.getByText('critical');
100
+ expect(element.tagName).toBe('EM');
101
+ expect(element).toHaveClass('wds-markup-emphasis', 'wds-markup-emphasis--important');
102
+ });
103
+
104
+ it('renders <positive> with correct class', () => {
105
+ render(<Markup>{'<positive>good</positive>'}</Markup>);
106
+ expect(screen.getByText('good')).toHaveClass('wds-markup-emphasis--positive');
107
+ });
108
+
109
+ it('renders <negative> with correct class', () => {
110
+ render(<Markup>{'<negative>bad</negative>'}</Markup>);
111
+ expect(screen.getByText('bad')).toHaveClass('wds-markup-emphasis--negative');
112
+ });
113
+
114
+ it('treats <warning> as plain text (unsupported tag)', () => {
115
+ render(<Markup>{'<warning>careful</warning>'}</Markup>);
116
+ expect(screen.getByText(/<warning>/)).toBeInTheDocument();
117
+ });
118
+ });
119
+
120
+ describe('links', () => {
121
+ it('renders a link with href', () => {
122
+ render(<Markup>{'<link href="/settings">settings</link>'}</Markup>);
123
+ const link = screen.getByRole('link', { name: 'settings' });
124
+ expect(link).toHaveAttribute('href', '/settings');
125
+ });
126
+
127
+ it('renders a link with target', () => {
128
+ render(<Markup>{'<link href="https://wise.com/help" target="_blank">visit</link>'}</Markup>);
129
+ const link = screen.getByRole('link', { name: /visit/ });
130
+ expect(link).toHaveAttribute('target', '_blank');
131
+ });
132
+
133
+ it('renders a button-mode link with action', async () => {
134
+ const handleSave = jest.fn();
135
+ render(<Markup actions={{ save: handleSave }}>{'<link action="save">Save</link>'}</Markup>);
136
+ const button = screen.getByRole('button', { name: 'Save' });
137
+ await userEvent.click(button);
138
+ expect(handleSave).toHaveBeenCalledTimes(1);
139
+ });
140
+
141
+ it('renders as plain text when action handler is missing', () => {
142
+ render(<Markup>{'<link action="missing">text</link>'}</Markup>);
143
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
144
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
145
+ expect(screen.getByText('text')).toBeInTheDocument();
146
+ });
147
+
148
+ it('renders as plain text when href is unsafe', () => {
149
+ render(<Markup>{'<link href="javascript:alert(1)">evil</link>'}</Markup>);
150
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
151
+ expect(screen.getByText('evil')).toBeInTheDocument();
152
+ });
153
+ });
154
+
155
+ describe('nesting', () => {
156
+ it('renders emphasis inside link', () => {
157
+ render(<Markup>{'<link href="/x"><important>bold link</important></link>'}</Markup>);
158
+ const link = screen.getByRole('link');
159
+ const emphasis = link.querySelector('em');
160
+ expect(emphasis).toHaveClass('wds-markup-emphasis--important');
161
+ expect(emphasis).toHaveTextContent('bold link');
162
+ });
163
+
164
+ it('renders link inside emphasis', () => {
165
+ render(<Markup>{'<important>Read the <link href="/terms">terms</link></important>'}</Markup>);
166
+ const emphasis = screen.getByText(/Read the/);
167
+ expect(emphasis.tagName).toBe('EM');
168
+ const link = screen.getByRole('link', { name: 'terms' });
169
+ expect(emphasis).toContainElement(link);
170
+ });
171
+ });
172
+
173
+ describe('strong', () => {
174
+ it('renders <strong> as a strong element', () => {
175
+ render(<Markup>{'<strong>bold</strong>'}</Markup>);
176
+ const element = screen.getByText('bold');
177
+ expect(element.tagName).toBe('STRONG');
178
+ });
179
+ });
180
+
181
+ describe('strikethrough', () => {
182
+ it('renders <strikethrough> as a del element', () => {
183
+ render(<Markup>{'<strikethrough>removed</strikethrough>'}</Markup>);
184
+ const element = screen.getByText('removed');
185
+ expect(element.tagName).toBe('DEL');
186
+ });
187
+ });
188
+
189
+ describe('paragraph', () => {
190
+ it('renders <paragraph> as a p element', () => {
191
+ render(<Markup>{'<paragraph>A paragraph.</paragraph>'}</Markup>);
192
+ const element = screen.getByText('A paragraph.');
193
+ expect(element.tagName).toBe('P');
194
+ });
195
+
196
+ it('renders <p> as a p element', () => {
197
+ render(<Markup>{'<p>Short form.</p>'}</Markup>);
198
+ const element = screen.getByText('Short form.');
199
+ expect(element.tagName).toBe('P');
200
+ });
201
+ });
202
+
203
+ describe('newlines', () => {
204
+ it('renders newlines as br elements', () => {
205
+ const { container } = render(<Markup>{'line one\nline two'}</Markup>);
206
+ expect(container.querySelector('br')).toBeInTheDocument();
207
+ });
208
+ });
209
+
210
+ describe('non-string children (pass-through)', () => {
211
+ it('passes through JSX elements unchanged', () => {
212
+ render(
213
+ <Markup>
214
+ <button type="button">Click me</button>
215
+ </Markup>,
216
+ );
217
+ expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
218
+ });
219
+
220
+ it('passes through a React component', () => {
221
+ const Custom = () => <span data-testid="custom">custom component</span>;
222
+ render(
223
+ <Markup>
224
+ <Custom />
225
+ </Markup>,
226
+ );
227
+ expect(screen.getByTestId('custom')).toBeInTheDocument();
228
+ });
229
+
230
+ it('applies className when passing through JSX', () => {
231
+ const { container } = render(
232
+ <Markup className="wrapper">
233
+ <em>styled</em>
234
+ </Markup>,
235
+ );
236
+ expect(container.firstChild).toHaveClass('wrapper');
237
+ });
238
+
239
+ it('returns null for undefined children', () => {
240
+ const { container } = render(<Markup>{undefined}</Markup>);
241
+ expect(container).toBeEmptyDOMElement();
242
+ });
243
+
244
+ it('returns null for null children', () => {
245
+ const { container } = render(<Markup>{null}</Markup>);
246
+ expect(container).toBeEmptyDOMElement();
247
+ });
248
+
249
+ it('passes through numbers as-is', () => {
250
+ render(<Markup>{42}</Markup>);
251
+ expect(screen.getByText('42')).toBeInTheDocument();
252
+ });
253
+
254
+ it('passes through fragment with mixed content', () => {
255
+ render(
256
+ <Markup>
257
+ <>
258
+ <strong>bold</strong> and text
259
+ </>
260
+ </Markup>,
261
+ );
262
+ expect(screen.getByText('bold')).toBeInTheDocument();
263
+ expect(screen.getByText(/and text/)).toBeInTheDocument();
264
+ });
265
+ });
266
+
267
+ describe('accessibilityLabel', () => {
268
+ it('applies aria-label to emphasis tags', () => {
269
+ render(
270
+ <Markup>{'<important accessibilityLabel="Important notice">critical</important>'}</Markup>,
271
+ );
272
+ const element = screen.getByText('critical');
273
+ expect(element).toHaveAttribute('aria-label', 'Important notice');
274
+ });
275
+
276
+ it('applies aria-label to strong tags', () => {
277
+ render(<Markup>{'<strong accessibilityLabel="Highlighted">bold</strong>'}</Markup>);
278
+ expect(screen.getByText('bold')).toHaveAttribute('aria-label', 'Highlighted');
279
+ });
280
+
281
+ it('applies aria-label to strikethrough tags', () => {
282
+ render(
283
+ <Markup>
284
+ {'<strikethrough accessibilityLabel="No longer valid">old price</strikethrough>'}
285
+ </Markup>,
286
+ );
287
+ expect(screen.getByText('old price')).toHaveAttribute('aria-label', 'No longer valid');
288
+ });
289
+
290
+ it('applies aria-label to link tags with href', () => {
291
+ render(
292
+ <Markup>
293
+ {'<link href="/settings" accessibilityLabel="Go to account settings">settings</link>'}
294
+ </Markup>,
295
+ );
296
+ const link = screen.getByRole('link', { name: 'Go to account settings' });
297
+ expect(link).toBeInTheDocument();
298
+ });
299
+
300
+ it('applies aria-label to button-mode link tags', () => {
301
+ const handler = jest.fn();
302
+ render(
303
+ <Markup actions={{ save: handler }}>
304
+ {'<link action="save" accessibilityLabel="Save your changes">Save</link>'}
305
+ </Markup>,
306
+ );
307
+ expect(screen.getByRole('button', { name: 'Save your changes' })).toBeInTheDocument();
308
+ });
309
+
310
+ it('does not apply aria-label to paragraph tags', () => {
311
+ render(<Markup>{'<paragraph accessibilityLabel="ignored">content</paragraph>'}</Markup>);
312
+ const element = screen.getByText('content');
313
+ expect(element.tagName).toBe('P');
314
+ expect(element).not.toHaveAttribute('aria-label');
315
+ });
316
+
317
+ it('does not render aria-label attribute when not provided', () => {
318
+ render(<Markup>{'<strong>plain bold</strong>'}</Markup>);
319
+ expect(screen.getByText('plain bold')).not.toHaveAttribute('aria-label');
320
+ });
321
+ });
322
+
323
+ describe('security', () => {
324
+ it('does not render script tags', () => {
325
+ const { container } = render(<Markup>{'<script>alert("xss")</script>'}</Markup>);
326
+ expect(container.querySelector('script')).not.toBeInTheDocument();
327
+ expect(container).toHaveTextContent('<script>alert("xss")</script>');
328
+ });
329
+ });
330
+
331
+ describe('data prop (placeholder substitution)', () => {
332
+ it('substitutes a single placeholder', () => {
333
+ render(<Markup data={{ name: 'John' }}>{'Hello, {{name}}!'}</Markup>);
334
+ expect(screen.getByText('Hello, John!')).toBeInTheDocument();
335
+ });
336
+
337
+ it('substitutes multiple placeholders', () => {
338
+ render(
339
+ <Markup data={{ amount: '£35.00', recipient: 'Alice' }}>
340
+ {'You sent {{amount}} to {{recipient}}'}
341
+ </Markup>,
342
+ );
343
+ expect(screen.getByText('You sent £35.00 to Alice')).toBeInTheDocument();
344
+ });
345
+
346
+ it('leaves unmatched placeholders as literal text', () => {
347
+ render(<Markup data={{ name: 'John' }}>{'Hello, {{name}}! Your code is {{code}}'}</Markup>);
348
+ expect(screen.getByText('Hello, John! Your code is {{code}}')).toBeInTheDocument();
349
+ });
350
+
351
+ it('converts numeric values to strings', () => {
352
+ render(<Markup data={{ count: 42 }}>{'You have {{count}} items'}</Markup>);
353
+ expect(screen.getByText('You have 42 items')).toBeInTheDocument();
354
+ });
355
+
356
+ it('substitutes placeholders inside nested markup', () => {
357
+ render(<Markup data={{ price: '£35.00' }}>{'Total: <strong>{{price}}</strong>'}</Markup>);
358
+ const strong = screen.getByText('£35.00');
359
+ expect(strong.tagName).toBe('STRONG');
360
+ });
361
+
362
+ it('renders without data prop (placeholders remain literal)', () => {
363
+ render(<Markup>{'Hello, {{name}}!'}</Markup>);
364
+ expect(screen.getByText('Hello, {{name}}!')).toBeInTheDocument();
365
+ });
366
+
367
+ it('does not parse markup tags inside data values', () => {
368
+ const { container } = render(
369
+ <Markup data={{ name: '<link href="https://evil.com">click</link>' }}>
370
+ {'Hello, {{name}}!'}
371
+ </Markup>,
372
+ );
373
+ expect(container.querySelector('a')).not.toBeInTheDocument();
374
+ expect(container).toHaveTextContent('Hello, <link href="https://evil.com">click</link>!');
375
+ });
376
+
377
+ it('does not parse emphasis tags inside data values', () => {
378
+ const { container } = render(
379
+ <Markup data={{ value: '<strong>injected</strong>' }}>{'Result: {{value}}'}</Markup>,
380
+ );
381
+ expect(container.querySelector('strong')).not.toBeInTheDocument();
382
+ expect(container).toHaveTextContent('Result: <strong>injected</strong>');
383
+ });
384
+
385
+ it('does not allow data values to break out of parent tags', () => {
386
+ const { container } = render(
387
+ <Markup data={{ price: '</strong><link href="https://evil.com">phish</link><strong>' }}>
388
+ {'Price: <strong>{{price}}</strong>'}
389
+ </Markup>,
390
+ );
391
+ expect(container.querySelector('a')).not.toBeInTheDocument();
392
+ const strong = container.querySelector('strong');
393
+ expect(strong).toHaveTextContent(
394
+ '</strong><link href="https://evil.com">phish</link><strong>',
395
+ );
396
+ });
397
+
398
+ it('substitutes the same placeholder used multiple times', () => {
399
+ render(
400
+ <Markup data={{ item: 'widget' }}>{'Buy a {{item}}! Everyone loves a {{item}}.'}</Markup>,
401
+ );
402
+ expect(screen.getByText('Buy a widget! Everyone loves a widget.')).toBeInTheDocument();
403
+ });
404
+ });
405
+
406
+ describe('allowLinks', () => {
407
+ it('renders root-relative links by default (internal mode)', () => {
408
+ render(<Markup>{'<link href="/settings">settings</link>'}</Markup>);
409
+ expect(screen.getByRole('link', { name: /settings/ })).toHaveAttribute('href', '/settings');
410
+ });
411
+
412
+ it('renders wise.com links by default (internal mode)', () => {
413
+ render(<Markup>{'<link href="https://wise.com/help">help</link>'}</Markup>);
414
+ expect(screen.getByRole('link', { name: /help/ })).toHaveAttribute(
415
+ 'href',
416
+ 'https://wise.com/help',
417
+ );
418
+ });
419
+
420
+ it('blocks external domains by default (internal mode)', () => {
421
+ render(<Markup>{'<link href="https://example.com">external</link>'}</Markup>);
422
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
423
+ expect(screen.getByText('external')).toBeInTheDocument();
424
+ });
425
+
426
+ it('allows external domains when set to "all"', () => {
427
+ render(
428
+ <Markup allowLinks="all">{'<link href="https://example.com">external</link>'}</Markup>,
429
+ );
430
+ expect(screen.getByRole('link', { name: /external/ })).toHaveAttribute(
431
+ 'href',
432
+ 'https://example.com',
433
+ );
434
+ });
435
+
436
+ it('blocks all links when set to "none"', () => {
437
+ render(<Markup allowLinks="none">{'<link href="/settings">settings</link>'}</Markup>);
438
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
439
+ expect(screen.getByText('settings')).toBeInTheDocument();
440
+ });
441
+
442
+ it('does not bypass base sanitisation even in "all" mode', () => {
443
+ render(<Markup allowLinks="all">{'<link href="javascript:alert(1)">xss</link>'}</Markup>);
444
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
445
+ });
446
+
447
+ it('does not affect action-based links in "none" mode', () => {
448
+ const handleClick = jest.fn();
449
+ render(
450
+ <Markup allowLinks="none" actions={{ save: handleClick }}>
451
+ {'<link action="save">save</link>'}
452
+ </Markup>,
453
+ );
454
+ expect(screen.getByRole('button', { name: /save/ })).toBeInTheDocument();
455
+ });
456
+
457
+ it('blocks root-relative paths that resolve to a different origin via URL parser', () => {
458
+ render(<Markup>{'<link href="/\\evil.com">sneaky</link>'}</Markup>);
459
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
460
+ expect(screen.getByText('sneaky')).toBeInTheDocument();
461
+ });
462
+
463
+ describe('HTML metacharacters in href are inert in React rendering context', () => {
464
+ it('renders href containing angle brackets without DOM injection', () => {
465
+ render(
466
+ <Markup allowLinks="all">
467
+ {'<link href="https://example.com/path?a=1&b=<script>alert(1)</script>">link</link>'}
468
+ </Markup>,
469
+ );
470
+ const link = screen.getByRole('link', { name: 'link' });
471
+ expect(link).toHaveAttribute(
472
+ 'href',
473
+ 'https://example.com/path?a=1&b=<script>alert(1)</script>',
474
+ );
475
+ // React escapes these in the DOM — no actual script element is injected
476
+ expect(link.closest('span')!.querySelector('script')).not.toBeInTheDocument();
477
+ });
478
+
479
+ it('renders href containing quotes without breaking the attribute', () => {
480
+ render(
481
+ <Markup allowLinks="all">
482
+ {'<link href="https://example.com/path?q=he said \'hello\'">link</link>'}
483
+ </Markup>,
484
+ );
485
+ const link = screen.getByRole('link', { name: 'link' });
486
+ // React safely renders the href via DOM property assignment, not innerHTML
487
+ expect(link).toHaveAttribute('href', "https://example.com/path?q=he said 'hello'");
488
+ });
489
+
490
+ it('renders href containing double quotes without attribute breakout', () => {
491
+ // The ATTR_REGEX uses single quotes here so the double quote is part of the value
492
+ render(
493
+ <Markup allowLinks="all">
494
+ {'<link href=\'https://example.com/path?q="injected"\'>link</link>'}
495
+ </Markup>,
496
+ );
497
+ const link = screen.getByRole('link', { name: 'link' });
498
+ expect(link).toHaveAttribute('href', 'https://example.com/path?q="injected"');
499
+ });
500
+ });
501
+ });
502
+ });
@@ -0,0 +1,175 @@
1
+ import React, { Fragment, useMemo } from 'react';
2
+
3
+ import Link from '../Link';
4
+
5
+ import { parseMarkup } from './utils/parseMarkup';
6
+ import { type AllowLinksMode, isHrefAllowedByMode, sanitiseHref } from './utils/sanitise';
7
+ import type { MarkupActions, MarkupNode, MarkupProps } from './Markup.types';
8
+
9
+ const PLACEHOLDER_REGEX = /\{\{(\w+)\}\}/g;
10
+
11
+ type MarkupData = Record<string, string | number>;
12
+
13
+ function substituteData(text: string, data: MarkupData): string {
14
+ return text.replace(PLACEHOLDER_REGEX, (match, key: string) =>
15
+ Object.prototype.hasOwnProperty.call(data, key) ? String(data[key]) : match,
16
+ );
17
+ }
18
+
19
+ function renderNodes(
20
+ nodes: MarkupNode[],
21
+ actions: MarkupActions | undefined,
22
+ data: MarkupData | undefined,
23
+ allowLinks: AllowLinksMode,
24
+ ): React.ReactNode[] {
25
+ return nodes.map((node, index) => renderNode(node, actions, data, allowLinks, index));
26
+ }
27
+
28
+ function renderLinkNode(
29
+ node: Extract<MarkupNode, { type: 'link' }>,
30
+ actions: MarkupActions | undefined,
31
+ data: MarkupData | undefined,
32
+ allowLinks: AllowLinksMode,
33
+ key: number,
34
+ ): React.ReactNode {
35
+ const children = renderNodes(node.children, actions, data, allowLinks);
36
+
37
+ if (node.action && actions?.[node.action]) {
38
+ return (
39
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events
40
+ <Link key={key} aria-label={node.accessibilityLabel} onClick={actions[node.action]}>
41
+ {children}
42
+ </Link>
43
+ );
44
+ }
45
+
46
+ const sanitisedHref = node.href ? sanitiseHref(node.href) : undefined;
47
+ if (sanitisedHref && isHrefAllowedByMode(sanitisedHref, allowLinks)) {
48
+ return (
49
+ <Link
50
+ key={key}
51
+ href={sanitisedHref}
52
+ target={node.target}
53
+ aria-label={node.accessibilityLabel}
54
+ >
55
+ {children}
56
+ </Link>
57
+ );
58
+ }
59
+
60
+ return <Fragment key={key}>{children}</Fragment>;
61
+ }
62
+
63
+ function renderNode(
64
+ node: MarkupNode,
65
+ actions: MarkupActions | undefined,
66
+ data: MarkupData | undefined,
67
+ allowLinks: AllowLinksMode,
68
+ key: number,
69
+ ): React.ReactNode {
70
+ switch (node.type) {
71
+ case 'text':
72
+ return (
73
+ <Fragment key={key}>{data ? substituteData(node.content, data) : node.content}</Fragment>
74
+ );
75
+ case 'newline':
76
+ return <br key={key} />;
77
+ case 'important':
78
+ case 'positive':
79
+ case 'negative':
80
+ return (
81
+ <em
82
+ key={key}
83
+ className={`wds-markup-emphasis wds-markup-emphasis--${node.type}`}
84
+ aria-label={node.accessibilityLabel}
85
+ >
86
+ {renderNodes(node.children, actions, data, allowLinks)}
87
+ </em>
88
+ );
89
+ case 'strong':
90
+ return (
91
+ <strong key={key} className="wds-markup-strong" aria-label={node.accessibilityLabel}>
92
+ {renderNodes(node.children, actions, data, allowLinks)}
93
+ </strong>
94
+ );
95
+ case 'strikethrough':
96
+ return (
97
+ <del key={key} className="wds-markup-strikethrough" aria-label={node.accessibilityLabel}>
98
+ {renderNodes(node.children, actions, data, allowLinks)}
99
+ </del>
100
+ );
101
+ case 'paragraph':
102
+ return (
103
+ <p key={key} className="wds-markup-paragraph">
104
+ {renderNodes(node.children, actions, data, allowLinks)}
105
+ </p>
106
+ );
107
+ case 'link':
108
+ return renderLinkNode(node, actions, data, allowLinks, key);
109
+ }
110
+ }
111
+
112
+ function hasParagraph(nodes: MarkupNode[]): boolean {
113
+ for (const node of nodes) {
114
+ if (node.type === 'paragraph') {
115
+ return true;
116
+ }
117
+ if ('children' in node && hasParagraph(node.children)) {
118
+ return true;
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+
124
+ /**
125
+ * Parses a markup string and renders it as React elements. Supports `<important>`,
126
+ * `<positive>`, `<negative>`, `<strong>`, `<strikethrough>`, `<paragraph>` (alias `<p>`),
127
+ * and `<link>` (alias `<a>`) with secure href validation.
128
+ *
129
+ * It only processes `string` input, while `ReactNode` is passed-through without processing.
130
+ *
131
+ * > The component supersedes [Emphasis](?path=/docs/typography-emphasis--docs), which will be
132
+ * eventually deprecated.
133
+ *
134
+ * @see http://storybook.wise.design/?path=/docs/typography-markup--docs
135
+ * @link ../emphasis/Emphasis | Emphasis
136
+ */
137
+ export const Markup: React.FC<MarkupProps> = ({
138
+ children,
139
+ data,
140
+ allowLinks = 'internal',
141
+ actions,
142
+ as,
143
+ className,
144
+ 'data-testid': testId,
145
+ }) => {
146
+ const tree = useMemo(
147
+ () => (typeof children === 'string' ? parseMarkup(children) : []),
148
+ [children],
149
+ );
150
+
151
+ if (typeof children !== 'string') {
152
+ if (children == null) {
153
+ return null;
154
+ }
155
+ return (
156
+ <span className={className} data-testid={testId}>
157
+ {children}
158
+ </span>
159
+ );
160
+ }
161
+
162
+ if (tree.length === 0) {
163
+ return null;
164
+ }
165
+
166
+ const Element = as ?? (hasParagraph(tree) ? 'div' : 'span');
167
+
168
+ return (
169
+ <Element className={className} data-testid={testId}>
170
+ {renderNodes(tree, actions, data, allowLinks)}
171
+ </Element>
172
+ );
173
+ };
174
+
175
+ export default Markup;