Skip to content

Commit 72f0936

Browse files
authored
fix(icon): load base64 data urls (#1172)
1 parent 6e7ff0d commit 72f0936

8 files changed

+74
-46
lines changed

src/components/icon/icon.tsx

+6-25
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import { Build, Component, Element, Host, Prop, State, Watch, h } from '@stencil
22
import { getSvgContent, ioniconContent } from './request';
33
import { getName, getUrl, inheritAttributes, isRTL } from './utils';
44

5-
let parser: DOMParser;
6-
75
@Component({
86
tag: 'ion-icon',
97
assetsDirs: ['svg'],
@@ -82,7 +80,7 @@ export class Icon {
8280
* @default true
8381
*/
8482
@Prop() sanitize = true;
85-
83+
8684
componentWillLoad() {
8785
this.inheritedAttributes = inheritAttributes(this.el, ['aria-label']);
8886
}
@@ -124,12 +122,12 @@ export class Icon {
124122
cb();
125123
}
126124
}
127-
125+
128126
private hasAriaHidden = () => {
129127
const { el } = this;
130-
128+
131129
return el.hasAttribute('aria-hidden') && el.getAttribute('aria-hidden') === 'true';
132-
}
130+
};
133131

134132
@Watch('name')
135133
@Watch('src')
@@ -138,35 +136,20 @@ export class Icon {
138136
@Watch('md')
139137
loadIcon() {
140138
if (Build.isBrowser && this.isVisible) {
141-
if (!parser) {
142-
/**
143-
* Create an instance of the DOM parser. This creates a single
144-
* parser instance for the entire app, which is more efficient.
145-
*/
146-
parser = new DOMParser();
147-
}
148139
const url = getUrl(this);
149140

150141
if (url) {
151142
if (ioniconContent.has(url)) {
152143
// sync if it's already loaded
153144
this.svgContent = ioniconContent.get(url);
154-
} else if (url.startsWith('data:')) {
155-
const doc = parser.parseFromString(url, 'text/html');
156-
const svgEl = doc.body.querySelector('svg');
157-
if (svgEl !== null) {
158-
this.svgContent = svgEl.outerHTML;
159-
} else {
160-
this.svgContent = '';
161-
}
162145
} else {
163146
// async if it hasn't been loaded
164147
getSvgContent(url, this.sanitize).then(() => (this.svgContent = ioniconContent.get(url)));
165148
}
166149
}
167150
}
168151

169-
const label = this.iconName = getName(this.name, this.icon, this.mode, this.ios, this.md);
152+
const label = (this.iconName = getName(this.name, this.icon, this.mode, this.ios, this.md));
170153

171154
/**
172155
* Come up with a default label
@@ -182,9 +165,7 @@ export class Icon {
182165
const mode = this.mode || 'md';
183166
const flipRtl =
184167
this.flipRtl ||
185-
(iconName &&
186-
(iconName.indexOf('arrow') > -1 || iconName.indexOf('chevron') > -1) &&
187-
this.flipRtl !== false);
168+
(iconName && (iconName.indexOf('arrow') > -1 || iconName.indexOf('chevron') > -1) && this.flipRtl !== false);
188169

189170
/**
190171
* Only set the aria-label if a) we have generated

src/components/icon/request.ts

+36-14
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,51 @@
1-
import { validateContent } from './validate';
1+
import { isEncodedDataUrl, isSvgDataUrl, validateContent } from './validate';
22

33
export const ioniconContent = new Map<string, string>();
44
const requests = new Map<string, Promise<any>>();
55

6+
let parser = new DOMParser();
7+
68
export const getSvgContent = (url: string, sanitize: boolean) => {
79
// see if we already have a request for this url
810
let req = requests.get(url);
911

1012
if (!req) {
1113
if (typeof fetch !== 'undefined' && typeof document !== 'undefined') {
12-
// we don't already have a request
13-
req = fetch(url).then((rsp) => {
14-
if (rsp.ok) {
15-
return rsp.text().then((svgContent) => {
16-
if (svgContent && sanitize !== false) {
17-
svgContent = validateContent(svgContent);
18-
}
19-
ioniconContent.set(url, svgContent || '');
20-
});
14+
/**
15+
* If the url is a data url of an svg, then try to parse it
16+
* with the DOMParser. This works with content security policies enabled.
17+
*/
18+
if (isSvgDataUrl(url) && isEncodedDataUrl(url)) {
19+
if (!parser) {
20+
/**
21+
* Create an instance of the DOM parser. This creates a single
22+
* parser instance for the entire app, which is more efficient.
23+
*/
24+
parser = new DOMParser();
25+
}
26+
const doc = parser.parseFromString(url, 'text/html');
27+
const svg = doc.querySelector('svg');
28+
if (svg) {
29+
ioniconContent.set(url, svg.outerHTML);
2130
}
22-
ioniconContent.set(url, '');
23-
});
31+
return Promise.resolve();
32+
} else {
33+
// we don't already have a request
34+
req = fetch(url).then((rsp) => {
35+
if (rsp.ok) {
36+
return rsp.text().then((svgContent) => {
37+
if (svgContent && sanitize !== false) {
38+
svgContent = validateContent(svgContent);
39+
}
40+
ioniconContent.set(url, svgContent || '');
41+
});
42+
}
43+
ioniconContent.set(url, '');
44+
});
45+
// cache for the same requests
46+
requests.set(url, req);
47+
}
2448

25-
// cache for the same requests
26-
requests.set(url, req);
2749
} else {
2850
// set to empty for ssr scenarios and resolve promise
2951
ioniconContent.set(url, '');
Loading
Loading
Loading

src/components/icon/test/validate.spec.ts

+25-7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isValid } from '../validate';
1+
import { isEncodedDataUrl, isSvgDataUrl, isValid } from '../validate';
22

33

44
describe('isValid', () => {
@@ -24,9 +24,11 @@ describe('isValid', () => {
2424
});
2525

2626
it('invalid child SCRIPT elm', () => {
27-
const el = { nodeType: 1, nodeName: 'svg', attributes: [], childNodes: [
28-
{ nodeType: 1, nodeName: 'SCRIPT', attributes: [], childNodes: [] }
29-
] } as any;
27+
const el = {
28+
nodeType: 1, nodeName: 'svg', attributes: [], childNodes: [
29+
{ nodeType: 1, nodeName: 'SCRIPT', attributes: [], childNodes: [] }
30+
]
31+
} as any;
3032
expect(isValid(el)).toBe(false);
3133
});
3234

@@ -41,9 +43,11 @@ describe('isValid', () => {
4143
});
4244

4345
it('is valid SVG elm', () => {
44-
const el = { nodeType: 1, nodeName: 'SVG', attributes: [], childNodes: [
45-
{ nodeType: 1, nodeName: 'line', attributes: [], childNodes: [] }
46-
] } as any;
46+
const el = {
47+
nodeType: 1, nodeName: 'SVG', attributes: [], childNodes: [
48+
{ nodeType: 1, nodeName: 'line', attributes: [], childNodes: [] }
49+
]
50+
} as any;
4751
expect(isValid(el)).toBe(true);
4852
});
4953

@@ -53,3 +57,17 @@ describe('isValid', () => {
5357
});
5458

5559
});
60+
61+
it('isSvgDataUrl', () => {
62+
expect(isSvgDataUrl('data:image/svg+xml;base64,xxx')).toBe(true);
63+
expect(isSvgDataUrl('data:image/svg+xml;utf8,<svg></svg>')).toBe(true);
64+
expect(isSvgDataUrl('https://example.com/icon.svg')).toBe(false);
65+
expect(isSvgDataUrl('http://example.com/icon.svg')).toBe(false);
66+
});
67+
68+
it('isEncodedDataUrl', () => {
69+
expect(isEncodedDataUrl('data:image/svg+xml;base64,xxx')).toBe(false);
70+
expect(isEncodedDataUrl('data:image/svg+xml;utf8,<svg></svg>')).toBe(true);
71+
expect(isEncodedDataUrl('https://example.com/icon.svg')).toBe(false);
72+
expect(isEncodedDataUrl('http://example.com/icon.svg')).toBe(false);
73+
});

src/components/icon/validate.ts

+3
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,6 @@ export const isValid = (elm: HTMLElement) => {
4848
}
4949
return true;
5050
};
51+
52+
export const isSvgDataUrl = (url: string) => url.startsWith('data:image/svg+xml');
53+
export const isEncodedDataUrl = (url: string) => url.indexOf(';utf8,') !== -1;

src/index.html

+4
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ <h2>Sanitized (shouldn't show)</h2>
136136
<h2>Not Sanitized (should show)</h2>
137137
<ion-icon sanitize="false" src="./assets/no-sanitize.svg"></ion-icon>
138138

139+
<h2>Base64 url</h2>
140+
<ion-icon
141+
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSJub25lIiBkPSJNMCAwaDI0djI0SDBWMHoiLz48cGF0aCBkPSJNMjAgMkg0Yy0xLjEgMC0yIC45LTIgMnYxOGw0LTRoMTRjMS4xIDAgMi0uOSAyLTJWNGMwLTEuMS0uOS0yLTItMnptMCAxNEg2bC0yIDJWNGgxNnYxMnoiLz48L3N2Zz4="></ion-icon>
142+
139143
<p>
140144
<a href="./cheatsheet.html">Cheatsheet</a>
141145
</p>

0 commit comments

Comments
 (0)