@transferwise/components 0.0.0-experimental-476a8e9 → 0.0.0-experimental-d23525e
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/build/i18n/ro.json +1 -1
- package/build/i18n/ro.json.js +1 -1
- package/build/i18n/ro.json.mjs +1 -1
- package/build/index.js +0 -2
- package/build/index.js.map +1 -1
- package/build/index.mjs +0 -1
- package/build/index.mjs.map +1 -1
- package/build/main.css +0 -35
- package/build/styles/main.css +0 -35
- package/build/types/body/Body.d.ts +2 -2
- package/build/types/iconButton/IconButton.d.ts +1 -1
- package/build/types/index.d.ts +1 -3
- package/build/types/index.d.ts.map +1 -1
- package/build/types/title/Title.d.ts +2 -2
- package/build/types/upload/steps/uploadImageStep/uploadImageStep.d.ts +1 -1
- package/build/types/uploadInput/uploadItem/UploadItemLink.d.ts +1 -1
- package/package.json +1 -1
- package/src/i18n/ro.json +1 -1
- package/src/index.ts +2 -2
- package/src/main.css +0 -35
- package/src/main.less +0 -1
- package/build/markup/Markup.js +0 -125
- package/build/markup/Markup.js.map +0 -1
- package/build/markup/Markup.mjs +0 -123
- package/build/markup/Markup.mjs.map +0 -1
- package/build/markup/constants.js +0 -36
- package/build/markup/constants.js.map +0 -1
- package/build/markup/constants.mjs +0 -29
- package/build/markup/constants.mjs.map +0 -1
- package/build/markup/utils/parseMarkup.js +0 -226
- package/build/markup/utils/parseMarkup.js.map +0 -1
- package/build/markup/utils/parseMarkup.mjs +0 -223
- package/build/markup/utils/parseMarkup.mjs.map +0 -1
- package/build/markup/utils/sanitise.js +0 -91
- package/build/markup/utils/sanitise.js.map +0 -1
- package/build/markup/utils/sanitise.mjs +0 -89
- package/build/markup/utils/sanitise.mjs.map +0 -1
- package/build/styles/markup/Markup.css +0 -28
- package/build/styles/markup/_stories/Vulnerability/Vulnerability.css +0 -70
- package/build/types/markup/Markup.d.ts +0 -4
- package/build/types/markup/Markup.d.ts.map +0 -1
- package/build/types/markup/Markup.types.d.ts +0 -58
- package/build/types/markup/Markup.types.d.ts.map +0 -1
- package/build/types/markup/_stories/Vulnerability/Vulnerability.d.ts +0 -12
- package/build/types/markup/_stories/Vulnerability/Vulnerability.d.ts.map +0 -1
- package/build/types/markup/constants.d.ts +0 -20
- package/build/types/markup/constants.d.ts.map +0 -1
- package/build/types/markup/index.d.ts +0 -3
- package/build/types/markup/index.d.ts.map +0 -1
- package/build/types/markup/utils/parseMarkup.d.ts +0 -6
- package/build/types/markup/utils/parseMarkup.d.ts.map +0 -1
- package/build/types/markup/utils/sanitise.d.ts +0 -2
- package/build/types/markup/utils/sanitise.d.ts.map +0 -1
- package/src/markup/Markup.css +0 -28
- package/src/markup/Markup.less +0 -29
- package/src/markup/Markup.test.tsx +0 -296
- package/src/markup/Markup.tsx +0 -130
- package/src/markup/Markup.types.ts +0 -43
- package/src/markup/_stories/Markup.docs.mdx +0 -75
- package/src/markup/_stories/Markup.security.docs.mdx +0 -256
- package/src/markup/_stories/Markup.story.tsx +0 -202
- package/src/markup/_stories/Vulnerability/Vulnerability.css +0 -70
- package/src/markup/_stories/Vulnerability/Vulnerability.tsx +0 -30
- package/src/markup/constants.ts +0 -41
- package/src/markup/index.ts +0 -2
- package/src/markup/utils/parseMarkup.test.ts +0 -658
- package/src/markup/utils/parseMarkup.ts +0 -228
- package/src/markup/utils/sanitise.test.ts +0 -375
- package/src/markup/utils/sanitise.ts +0 -107
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../src/markup/constants.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,YAAY,0GAUf,CAAC;AAEX;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,SAAS,QAGrB,CAAC;AAEF,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAiC,CAAC;AAGjF,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAGpC,eAAO,MAAM,eAAe,UAAsB,CAAC;AAGnD,eAAO,MAAM,iBAAiB,UAAwB,CAAC"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/markup/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAClC,YAAY,EAAE,mBAAmB,EAAE,aAAa,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC"}
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
import { MAX_NESTING_DEPTH } from '../constants';
|
|
2
|
-
import { MarkupNode } from '../Markup.types';
|
|
3
|
-
export { MAX_NESTING_DEPTH };
|
|
4
|
-
/** Entry point: tokenises the input string, then parses the token stream into a MarkupNode tree. */
|
|
5
|
-
export declare function parseMarkup(input: string): MarkupNode[];
|
|
6
|
-
//# sourceMappingURL=parseMarkup.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"parseMarkup.d.ts","sourceRoot":"","sources":["../../../../src/markup/utils/parseMarkup.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmB,iBAAiB,EAA0B,MAAM,cAAc,CAAC;AAC1F,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,OAAO,EAAE,iBAAiB,EAAE,CAAC;AAuN7B,oGAAoG;AACpG,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,EAAE,CAQvD"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"sanitise.d.ts","sourceRoot":"","sources":["../../../../src/markup/utils/sanitise.ts"],"names":[],"mappings":"AAaA,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAmFnD"}
|
package/src/markup/Markup.css
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
.wds-markup-emphasis {
|
|
2
|
-
font-weight: 600;
|
|
3
|
-
font-weight: var(--font-weight-semi-bold);
|
|
4
|
-
font-style: normal;
|
|
5
|
-
}
|
|
6
|
-
.wds-markup-emphasis--important {
|
|
7
|
-
color: #37517e;
|
|
8
|
-
color: var(--color-content-primary);
|
|
9
|
-
}
|
|
10
|
-
.wds-markup-emphasis--positive {
|
|
11
|
-
color: #008026;
|
|
12
|
-
color: var(--color-content-positive);
|
|
13
|
-
}
|
|
14
|
-
.wds-markup-emphasis--negative {
|
|
15
|
-
color: #cf2929;
|
|
16
|
-
color: var(--color-content-negative);
|
|
17
|
-
}
|
|
18
|
-
.wds-markup-strong {
|
|
19
|
-
font-weight: 600;
|
|
20
|
-
font-weight: var(--font-weight-semi-bold);
|
|
21
|
-
}
|
|
22
|
-
.wds-markup-strikethrough {
|
|
23
|
-
-webkit-text-decoration: line-through;
|
|
24
|
-
text-decoration: line-through;
|
|
25
|
-
}
|
|
26
|
-
.wds-markup-paragraph {
|
|
27
|
-
margin: 0;
|
|
28
|
-
}
|
package/src/markup/Markup.less
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
.wds-markup-emphasis {
|
|
2
|
-
font-weight: var(--font-weight-semi-bold);
|
|
3
|
-
font-style: normal;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
.wds-markup-emphasis--important {
|
|
7
|
-
color: var(--color-content-primary);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
.wds-markup-emphasis--positive {
|
|
11
|
-
color: var(--color-content-positive);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
.wds-markup-emphasis--negative {
|
|
15
|
-
color: var(--color-content-negative);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
.wds-markup-strong {
|
|
19
|
-
font-weight: var(--font-weight-semi-bold);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/// @see https://www.webaxe.org/strikethrough-html-accessibility/
|
|
23
|
-
.wds-markup-strikethrough {
|
|
24
|
-
text-decoration: line-through;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
.wds-markup-paragraph {
|
|
28
|
-
margin: 0;
|
|
29
|
-
}
|
|
@@ -1,296 +0,0 @@
|
|
|
1
|
-
import { render, screen, userEvent } from '../test-utils';
|
|
2
|
-
|
|
3
|
-
import { Markup } from './Markup';
|
|
4
|
-
|
|
5
|
-
describe('Markup', () => {
|
|
6
|
-
it('renders plain text', () => {
|
|
7
|
-
render(<Markup>hello world</Markup>);
|
|
8
|
-
expect(screen.getByText('hello world')).toBeInTheDocument();
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it('returns null for empty children', () => {
|
|
12
|
-
const { container } = render(<Markup />);
|
|
13
|
-
expect(container).toBeEmptyDOMElement();
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
describe('wrapper element', () => {
|
|
17
|
-
it('renders in a span wrapper for inline content', () => {
|
|
18
|
-
render(<Markup>content</Markup>);
|
|
19
|
-
const element = screen.getByText('content');
|
|
20
|
-
expect(element.tagName).toBe('SPAN');
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('renders in a div wrapper when paragraph nodes are present', () => {
|
|
24
|
-
const { container } = render(<Markup>{'<p>block content</p>'}</Markup>);
|
|
25
|
-
const wrapper = container.firstChild as HTMLElement;
|
|
26
|
-
expect(wrapper.tagName).toBe('DIV');
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('renders in a div wrapper when nested paragraph nodes are present', () => {
|
|
30
|
-
const { container } = render(
|
|
31
|
-
<Markup>{'<important><p>nested paragraph</p></important>'}</Markup>,
|
|
32
|
-
);
|
|
33
|
-
const wrapper = container.firstChild as HTMLElement;
|
|
34
|
-
expect(wrapper.tagName).toBe('DIV');
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('uses span when as="span" even with paragraphs', () => {
|
|
38
|
-
const { container } = render(<Markup as="span">{'<p>forced span</p>'}</Markup>);
|
|
39
|
-
const wrapper = container.firstChild as HTMLElement;
|
|
40
|
-
expect(wrapper.tagName).toBe('SPAN');
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('uses div when as="div" even without paragraphs', () => {
|
|
44
|
-
const { container } = render(<Markup as="div">inline but div</Markup>);
|
|
45
|
-
const wrapper = container.firstChild as HTMLElement;
|
|
46
|
-
expect(wrapper.tagName).toBe('DIV');
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('applies className to wrapper regardless of element type', () => {
|
|
50
|
-
render(<Markup className="custom">content</Markup>);
|
|
51
|
-
expect(screen.getByText('content')).toHaveClass('custom');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('applies className to div wrapper with paragraphs', () => {
|
|
55
|
-
const { container } = render(<Markup className="block-wrapper">{'<p>paragraph</p>'}</Markup>);
|
|
56
|
-
const wrapper = container.firstChild as HTMLElement;
|
|
57
|
-
expect(wrapper).toHaveClass('block-wrapper');
|
|
58
|
-
expect(wrapper.tagName).toBe('DIV');
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
describe('emphasis', () => {
|
|
63
|
-
it('renders <important> with correct class', () => {
|
|
64
|
-
render(<Markup>{'<important>critical</important>'}</Markup>);
|
|
65
|
-
const element = screen.getByText('critical');
|
|
66
|
-
expect(element.tagName).toBe('EM');
|
|
67
|
-
expect(element).toHaveClass('wds-markup-emphasis', 'wds-markup-emphasis--important');
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('renders <positive> with correct class', () => {
|
|
71
|
-
render(<Markup>{'<positive>good</positive>'}</Markup>);
|
|
72
|
-
expect(screen.getByText('good')).toHaveClass('wds-markup-emphasis--positive');
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('renders <negative> with correct class', () => {
|
|
76
|
-
render(<Markup>{'<negative>bad</negative>'}</Markup>);
|
|
77
|
-
expect(screen.getByText('bad')).toHaveClass('wds-markup-emphasis--negative');
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('treats <warning> as plain text (unsupported tag)', () => {
|
|
81
|
-
render(<Markup>{'<warning>careful</warning>'}</Markup>);
|
|
82
|
-
expect(screen.getByText(/<warning>/)).toBeInTheDocument();
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
describe('links', () => {
|
|
87
|
-
it('renders a link with href', () => {
|
|
88
|
-
render(<Markup>{'<link href="/settings">settings</link>'}</Markup>);
|
|
89
|
-
const link = screen.getByRole('link', { name: 'settings' });
|
|
90
|
-
expect(link).toHaveAttribute('href', '/settings');
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('renders a link with target', () => {
|
|
94
|
-
render(<Markup>{'<link href="https://example.com" target="_blank">visit</link>'}</Markup>);
|
|
95
|
-
const link = screen.getByRole('link', { name: /visit/ });
|
|
96
|
-
expect(link).toHaveAttribute('target', '_blank');
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('renders a button-mode link with action', async () => {
|
|
100
|
-
const handleSave = jest.fn();
|
|
101
|
-
render(<Markup actions={{ save: handleSave }}>{'<link action="save">Save</link>'}</Markup>);
|
|
102
|
-
const button = screen.getByRole('button', { name: 'Save' });
|
|
103
|
-
await userEvent.click(button);
|
|
104
|
-
expect(handleSave).toHaveBeenCalledTimes(1);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('renders as plain text when action handler is missing', () => {
|
|
108
|
-
render(<Markup>{'<link action="missing">text</link>'}</Markup>);
|
|
109
|
-
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
|
110
|
-
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
|
111
|
-
expect(screen.getByText('text')).toBeInTheDocument();
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('renders as plain text when href is unsafe', () => {
|
|
115
|
-
render(<Markup>{'<link href="javascript:alert(1)">evil</link>'}</Markup>);
|
|
116
|
-
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
|
117
|
-
expect(screen.getByText('evil')).toBeInTheDocument();
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
describe('nesting', () => {
|
|
122
|
-
it('renders emphasis inside link', () => {
|
|
123
|
-
render(<Markup>{'<link href="/x"><important>bold link</important></link>'}</Markup>);
|
|
124
|
-
const link = screen.getByRole('link');
|
|
125
|
-
const emphasis = link.querySelector('em');
|
|
126
|
-
expect(emphasis).toHaveClass('wds-markup-emphasis--important');
|
|
127
|
-
expect(emphasis).toHaveTextContent('bold link');
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('renders link inside emphasis', () => {
|
|
131
|
-
render(<Markup>{'<important>Read the <link href="/terms">terms</link></important>'}</Markup>);
|
|
132
|
-
const emphasis = screen.getByText(/Read the/);
|
|
133
|
-
expect(emphasis.tagName).toBe('EM');
|
|
134
|
-
const link = screen.getByRole('link', { name: 'terms' });
|
|
135
|
-
expect(emphasis).toContainElement(link);
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
describe('strong', () => {
|
|
140
|
-
it('renders <strong> as a strong element', () => {
|
|
141
|
-
render(<Markup>{'<strong>bold</strong>'}</Markup>);
|
|
142
|
-
const element = screen.getByText('bold');
|
|
143
|
-
expect(element.tagName).toBe('STRONG');
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
describe('strikethrough', () => {
|
|
148
|
-
it('renders <strikethrough> as a del element', () => {
|
|
149
|
-
render(<Markup>{'<strikethrough>removed</strikethrough>'}</Markup>);
|
|
150
|
-
const element = screen.getByText('removed');
|
|
151
|
-
expect(element.tagName).toBe('DEL');
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
describe('paragraph', () => {
|
|
156
|
-
it('renders <paragraph> as a p element', () => {
|
|
157
|
-
render(<Markup>{'<paragraph>A paragraph.</paragraph>'}</Markup>);
|
|
158
|
-
const element = screen.getByText('A paragraph.');
|
|
159
|
-
expect(element.tagName).toBe('P');
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('renders <p> as a p element', () => {
|
|
163
|
-
render(<Markup>{'<p>Short form.</p>'}</Markup>);
|
|
164
|
-
const element = screen.getByText('Short form.');
|
|
165
|
-
expect(element.tagName).toBe('P');
|
|
166
|
-
});
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
describe('newlines', () => {
|
|
170
|
-
it('renders newlines as br elements', () => {
|
|
171
|
-
const { container } = render(<Markup>{'line one\nline two'}</Markup>);
|
|
172
|
-
expect(container.querySelector('br')).toBeInTheDocument();
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
describe('non-string children (pass-through)', () => {
|
|
177
|
-
it('passes through JSX elements unchanged', () => {
|
|
178
|
-
render(
|
|
179
|
-
<Markup>
|
|
180
|
-
<button type="button">Click me</button>
|
|
181
|
-
</Markup>,
|
|
182
|
-
);
|
|
183
|
-
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it('passes through a React component', () => {
|
|
187
|
-
const Custom = () => <span data-testid="custom">custom component</span>;
|
|
188
|
-
render(
|
|
189
|
-
<Markup>
|
|
190
|
-
<Custom />
|
|
191
|
-
</Markup>,
|
|
192
|
-
);
|
|
193
|
-
expect(screen.getByTestId('custom')).toBeInTheDocument();
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it('applies className when passing through JSX', () => {
|
|
197
|
-
const { container } = render(
|
|
198
|
-
<Markup className="wrapper">
|
|
199
|
-
<em>styled</em>
|
|
200
|
-
</Markup>,
|
|
201
|
-
);
|
|
202
|
-
expect(container.firstChild).toHaveClass('wrapper');
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it('returns null for undefined children', () => {
|
|
206
|
-
const { container } = render(<Markup>{undefined}</Markup>);
|
|
207
|
-
expect(container).toBeEmptyDOMElement();
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it('returns null for null children', () => {
|
|
211
|
-
const { container } = render(<Markup>{null}</Markup>);
|
|
212
|
-
expect(container).toBeEmptyDOMElement();
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it('passes through numbers as-is', () => {
|
|
216
|
-
render(<Markup>{42}</Markup>);
|
|
217
|
-
expect(screen.getByText('42')).toBeInTheDocument();
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it('passes through fragment with mixed content', () => {
|
|
221
|
-
render(
|
|
222
|
-
<Markup>
|
|
223
|
-
<>
|
|
224
|
-
<strong>bold</strong> and text
|
|
225
|
-
</>
|
|
226
|
-
</Markup>,
|
|
227
|
-
);
|
|
228
|
-
expect(screen.getByText('bold')).toBeInTheDocument();
|
|
229
|
-
expect(screen.getByText(/and text/)).toBeInTheDocument();
|
|
230
|
-
});
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
describe('accessibilityLabel', () => {
|
|
234
|
-
it('applies aria-label to emphasis tags', () => {
|
|
235
|
-
render(
|
|
236
|
-
<Markup>{'<important accessibilityLabel="Important notice">critical</important>'}</Markup>,
|
|
237
|
-
);
|
|
238
|
-
const element = screen.getByText('critical');
|
|
239
|
-
expect(element).toHaveAttribute('aria-label', 'Important notice');
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
it('applies aria-label to strong tags', () => {
|
|
243
|
-
render(<Markup>{'<strong accessibilityLabel="Highlighted">bold</strong>'}</Markup>);
|
|
244
|
-
expect(screen.getByText('bold')).toHaveAttribute('aria-label', 'Highlighted');
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it('applies aria-label to strikethrough tags', () => {
|
|
248
|
-
render(
|
|
249
|
-
<Markup>
|
|
250
|
-
{'<strikethrough accessibilityLabel="No longer valid">old price</strikethrough>'}
|
|
251
|
-
</Markup>,
|
|
252
|
-
);
|
|
253
|
-
expect(screen.getByText('old price')).toHaveAttribute('aria-label', 'No longer valid');
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it('applies aria-label to link tags with href', () => {
|
|
257
|
-
render(
|
|
258
|
-
<Markup>
|
|
259
|
-
{'<link href="/settings" accessibilityLabel="Go to account settings">settings</link>'}
|
|
260
|
-
</Markup>,
|
|
261
|
-
);
|
|
262
|
-
const link = screen.getByRole('link', { name: 'Go to account settings' });
|
|
263
|
-
expect(link).toBeInTheDocument();
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it('applies aria-label to button-mode link tags', () => {
|
|
267
|
-
const handler = jest.fn();
|
|
268
|
-
render(
|
|
269
|
-
<Markup actions={{ save: handler }}>
|
|
270
|
-
{'<link action="save" accessibilityLabel="Save your changes">Save</link>'}
|
|
271
|
-
</Markup>,
|
|
272
|
-
);
|
|
273
|
-
expect(screen.getByRole('button', { name: 'Save your changes' })).toBeInTheDocument();
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
it('does not apply aria-label to paragraph tags', () => {
|
|
277
|
-
render(<Markup>{'<paragraph accessibilityLabel="ignored">content</paragraph>'}</Markup>);
|
|
278
|
-
const element = screen.getByText('content');
|
|
279
|
-
expect(element.tagName).toBe('P');
|
|
280
|
-
expect(element).not.toHaveAttribute('aria-label');
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
it('does not render aria-label attribute when not provided', () => {
|
|
284
|
-
render(<Markup>{'<strong>plain bold</strong>'}</Markup>);
|
|
285
|
-
expect(screen.getByText('plain bold')).not.toHaveAttribute('aria-label');
|
|
286
|
-
});
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
describe('security', () => {
|
|
290
|
-
it('does not render script tags', () => {
|
|
291
|
-
const { container } = render(<Markup>{'<script>alert("xss")</script>'}</Markup>);
|
|
292
|
-
expect(container.querySelector('script')).not.toBeInTheDocument();
|
|
293
|
-
expect(container).toHaveTextContent('<script>alert("xss")</script>');
|
|
294
|
-
});
|
|
295
|
-
});
|
|
296
|
-
});
|
package/src/markup/Markup.tsx
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import React, { Fragment, useMemo } from 'react';
|
|
2
|
-
|
|
3
|
-
import Link from '../link';
|
|
4
|
-
|
|
5
|
-
import { parseMarkup } from './utils/parseMarkup';
|
|
6
|
-
import { isAllowedHref } from './utils/sanitise';
|
|
7
|
-
import type { MarkupActions, MarkupNode, MarkupProps } from './Markup.types';
|
|
8
|
-
|
|
9
|
-
function renderNodes(nodes: MarkupNode[], actions?: MarkupActions): React.ReactNode[] {
|
|
10
|
-
return nodes.map((node, index) => renderNode(node, actions, index));
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function renderLinkNode(
|
|
14
|
-
node: Extract<MarkupNode, { type: 'link' }>,
|
|
15
|
-
actions: MarkupActions | undefined,
|
|
16
|
-
key: number,
|
|
17
|
-
): React.ReactNode {
|
|
18
|
-
const children = renderNodes(node.children, actions);
|
|
19
|
-
|
|
20
|
-
if (node.action && actions?.[node.action]) {
|
|
21
|
-
return (
|
|
22
|
-
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
|
|
23
|
-
<Link key={key} aria-label={node.accessibilityLabel} onClick={actions[node.action]}>
|
|
24
|
-
{children}
|
|
25
|
-
</Link>
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
if (node.href && isAllowedHref(node.href)) {
|
|
30
|
-
return (
|
|
31
|
-
<Link key={key} href={node.href} target={node.target} aria-label={node.accessibilityLabel}>
|
|
32
|
-
{children}
|
|
33
|
-
</Link>
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
return <Fragment key={key}>{children}</Fragment>;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function renderNode(
|
|
41
|
-
node: MarkupNode,
|
|
42
|
-
actions: MarkupActions | undefined,
|
|
43
|
-
key: number,
|
|
44
|
-
): React.ReactNode {
|
|
45
|
-
switch (node.type) {
|
|
46
|
-
case 'text':
|
|
47
|
-
return <Fragment key={key}>{node.content}</Fragment>;
|
|
48
|
-
case 'newline':
|
|
49
|
-
return <br key={key} />;
|
|
50
|
-
case 'important':
|
|
51
|
-
case 'positive':
|
|
52
|
-
case 'negative':
|
|
53
|
-
return (
|
|
54
|
-
<em
|
|
55
|
-
key={key}
|
|
56
|
-
className={`wds-markup-emphasis wds-markup-emphasis--${node.type}`}
|
|
57
|
-
aria-label={node.accessibilityLabel}
|
|
58
|
-
>
|
|
59
|
-
{renderNodes(node.children, actions)}
|
|
60
|
-
</em>
|
|
61
|
-
);
|
|
62
|
-
case 'strong':
|
|
63
|
-
return (
|
|
64
|
-
<strong key={key} className="wds-markup-strong" aria-label={node.accessibilityLabel}>
|
|
65
|
-
{renderNodes(node.children, actions)}
|
|
66
|
-
</strong>
|
|
67
|
-
);
|
|
68
|
-
case 'strikethrough':
|
|
69
|
-
return (
|
|
70
|
-
<del key={key} className="wds-markup-strikethrough" aria-label={node.accessibilityLabel}>
|
|
71
|
-
{renderNodes(node.children, actions)}
|
|
72
|
-
</del>
|
|
73
|
-
);
|
|
74
|
-
case 'paragraph':
|
|
75
|
-
return (
|
|
76
|
-
<p key={key} className="wds-markup-paragraph">
|
|
77
|
-
{renderNodes(node.children, actions)}
|
|
78
|
-
</p>
|
|
79
|
-
);
|
|
80
|
-
case 'link':
|
|
81
|
-
return renderLinkNode(node, actions, key);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Parses a markup string and renders it as React elements. Supports `<important>`,
|
|
87
|
-
* `<positive>`, `<negative>`, `<strong>`, `<strikethrough>`, `<paragraph>` (alias `<p>`),
|
|
88
|
-
* and `<link>` (alias `<a>`) with secure href validation.
|
|
89
|
-
*
|
|
90
|
-
* It only processes `string` input, while `ReactNode` is passed-through without processing.
|
|
91
|
-
*
|
|
92
|
-
* > The component supersedes [Emphasis](?path=/docs/typography-emphasis--docs), which will be
|
|
93
|
-
* eventually deprecated.
|
|
94
|
-
*
|
|
95
|
-
* @see http://storybook.wise.design/?path=/docs/typography-markup--docs
|
|
96
|
-
* @link ../emphasis/Emphasis | Emphasis
|
|
97
|
-
*/
|
|
98
|
-
function hasParagraph(nodes: MarkupNode[]): boolean {
|
|
99
|
-
for (const node of nodes) {
|
|
100
|
-
if (node.type === 'paragraph') {
|
|
101
|
-
return true;
|
|
102
|
-
}
|
|
103
|
-
if ('children' in node && hasParagraph(node.children)) {
|
|
104
|
-
return true;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
return false;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export const Markup: React.FC<MarkupProps> = ({ children, actions, as, className }) => {
|
|
111
|
-
const tree = useMemo(
|
|
112
|
-
() => (typeof children === 'string' ? parseMarkup(children) : []),
|
|
113
|
-
[children],
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
if (typeof children !== 'string') {
|
|
117
|
-
if (children == null) {
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
return <span className={className}>{children}</span>;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (tree.length === 0) {
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const Element = as ?? (hasParagraph(tree) ? 'div' : 'span');
|
|
128
|
-
|
|
129
|
-
return <Element className={className}>{renderNodes(tree, actions)}</Element>;
|
|
130
|
-
};
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
|
|
3
|
-
import type { LinkProps } from '../link';
|
|
4
|
-
|
|
5
|
-
export type MarkupNode =
|
|
6
|
-
| { type: 'text'; content: string }
|
|
7
|
-
| { type: 'newline' }
|
|
8
|
-
| { type: 'important'; accessibilityLabel?: string; children: MarkupNode[] }
|
|
9
|
-
| { type: 'positive'; accessibilityLabel?: string; children: MarkupNode[] }
|
|
10
|
-
| { type: 'negative'; accessibilityLabel?: string; children: MarkupNode[] }
|
|
11
|
-
| { type: 'strong'; accessibilityLabel?: string; children: MarkupNode[] }
|
|
12
|
-
| { type: 'strikethrough'; accessibilityLabel?: string; children: MarkupNode[] }
|
|
13
|
-
| { type: 'paragraph'; children: MarkupNode[] }
|
|
14
|
-
| {
|
|
15
|
-
type: 'link';
|
|
16
|
-
href?: string;
|
|
17
|
-
action?: string;
|
|
18
|
-
target?: string;
|
|
19
|
-
accessibilityLabel?: string;
|
|
20
|
-
children: MarkupNode[];
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
export type MarkupActionHandler = NonNullable<LinkProps['onClick']>;
|
|
24
|
-
|
|
25
|
-
export type MarkupActions = Record<string, MarkupActionHandler>;
|
|
26
|
-
|
|
27
|
-
export interface MarkupProps {
|
|
28
|
-
/**
|
|
29
|
-
* Markup string to parse and render, or arbitrary JSX to pass through unchanged.
|
|
30
|
-
* Parsing only occurs when children is a string.
|
|
31
|
-
* */
|
|
32
|
-
children?: React.ReactNode;
|
|
33
|
-
/**
|
|
34
|
-
* Map of action names to click handlers for `<link action="name">` button-mode links.
|
|
35
|
-
* */
|
|
36
|
-
actions?: MarkupActions;
|
|
37
|
-
/**
|
|
38
|
-
* Wrapper element. Defaults to `div` when the parsed tree contains `<paragraph>` nodes,
|
|
39
|
-
* `span` otherwise. Set explicitly to override auto-detection.
|
|
40
|
-
* */
|
|
41
|
-
as?: 'span' | 'div';
|
|
42
|
-
className?: string;
|
|
43
|
-
}
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { Meta, Source } from '@storybook/addon-docs/blocks';
|
|
2
|
-
|
|
3
|
-
<Meta title="Typography/Markup/Developer notes" />
|
|
4
|
-
|
|
5
|
-
# Developer notes
|
|
6
|
-
|
|
7
|
-
## How parsing works
|
|
8
|
-
|
|
9
|
-
The parser runs in two phases: **tokenise** then **parse**.
|
|
10
|
-
|
|
11
|
-
### Phase 1: Tokenise
|
|
12
|
-
|
|
13
|
-
The input string is scanned left-to-right with a regex that only recognises allowed tag names. Everything between matches becomes text. Newlines become their own tokens.
|
|
14
|
-
|
|
15
|
-
<Source dark language="text" code={`
|
|
16
|
-
Input: "Hello <important>world</important>!\\nBye"
|
|
17
|
-
|
|
18
|
-
Tokens: [ TEXT:"Hello " ]
|
|
19
|
-
[ OPEN:important ]
|
|
20
|
-
[ TEXT:"world" ]
|
|
21
|
-
[ CLOSE:important ]
|
|
22
|
-
[ TEXT:"!" ]
|
|
23
|
-
[ NEWLINE ]
|
|
24
|
-
[ TEXT:"Bye" ]
|
|
25
|
-
`} />
|
|
26
|
-
|
|
27
|
-
Unrecognised tags like `<script>` or `<div>` are never tokenised — they stay as part of the text content. This is the first security layer.
|
|
28
|
-
|
|
29
|
-
### Phase 2: Recursive descent parse
|
|
30
|
-
|
|
31
|
-
The token stream is consumed by a recursive parser that builds a tree of nodes.
|
|
32
|
-
|
|
33
|
-
<Source
|
|
34
|
-
dark
|
|
35
|
-
language="text"
|
|
36
|
-
code={`
|
|
37
|
-
parse(tokens, until=none)
|
|
38
|
-
│
|
|
39
|
-
├─ TEXT:"Hello " ────────────────────► { type: 'text', content: 'Hello ' }
|
|
40
|
-
├─ OPEN:important
|
|
41
|
-
│ │
|
|
42
|
-
│ ├─ parse(tokens, until='important') // recurse
|
|
43
|
-
│ │ ├─ TEXT:"world" ───────────────► { type: 'text', content: 'world' }
|
|
44
|
-
│ │ └─ CLOSE:important ────────────► return { closed: true, nodes: [...] }
|
|
45
|
-
│ │
|
|
46
|
-
│ └─ result.closed? YES ────────────► { type: 'important', children: [...] }
|
|
47
|
-
│
|
|
48
|
-
├─ TEXT:"!" ─────────────────────────► { type: 'text', content: '!' }
|
|
49
|
-
├─ NEWLINE ──────────────────────────► { type: 'newline' }
|
|
50
|
-
└─ TEXT:"Bye" ───────────────────────► { type: 'text', content: 'Bye' }
|
|
51
|
-
`}
|
|
52
|
-
/>
|
|
53
|
-
|
|
54
|
-
The key insight is the `closed` flag. When we see an open tag, we recurse to collect its children. Two outcomes:
|
|
55
|
-
|
|
56
|
-
1. **Matching close tag found:** build a proper node with children.
|
|
57
|
-
2. **No matching close tag found:** treat the open tag as literal text, and return the already-parsed children.
|
|
58
|
-
|
|
59
|
-
This gives graceful degradation for malformed input — no crashes, no swallowed content.
|
|
60
|
-
|
|
61
|
-
### Nesting
|
|
62
|
-
|
|
63
|
-
Because each open tag recurses, nesting is handled naturally:
|
|
64
|
-
|
|
65
|
-
<Source dark language="text" code={`
|
|
66
|
-
Input: "<p><important>Read the <a href="/terms">terms</a></important></p>"
|
|
67
|
-
|
|
68
|
-
Tree: paragraph
|
|
69
|
-
└─ important
|
|
70
|
-
├─ text: "Read the "
|
|
71
|
-
└─ link (href="/terms")
|
|
72
|
-
└─ text: "terms"
|
|
73
|
-
`} />
|
|
74
|
-
|
|
75
|
-
The parser enforces a `MAX_NESTING_DEPTH` limit (32). Beyond that depth, tags are emitted as literal text instead of recursing — this prevents stack overflow from deeply-nested input.
|