Skip to content

Commit d350da8

Browse files
fix: nested stylesheets should have absolute URLs (#1533)
* Replace relative URLs with absolute URLs when stringifying stylesheets * Add test to show desired behavior for imported stylesheets from seperate directory * Rename `absoluteToStylesheet` to `absolutifyURLs` and call it once after stringifying imported stylesheet * Don't create the intermediary array of the spread operator * Formalize that `stringifyRule` should expect a sheet href * Ensure a <style> element can also import and gets it's url absolutized * Handle case where non imported stylesheet has relative urls that need to be absolutified * Clarify in test files where jpegs are expected to appear in absolutified urls * Move absolutifyURLs call for import rules out of trycatch * Add a benchmarking test for stringifyStylesheet * Avoid the duplication on how to fall back --------- Co-authored-by: Eoghan Murray <eoghan@getthere.ie> Co-authored-by: eoghanmurray <eoghanmurray@users.noreply.github.com>
1 parent 8059d96 commit d350da8

File tree

13 files changed

+175
-108
lines changed

13 files changed

+175
-108
lines changed

.changeset/six-llamas-brush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"rrweb-snapshot": patch
3+
---
4+
5+
Fix `url()` rewrite for nested stylesheets by rewriting during stringification instead of after

packages/rrweb-snapshot/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"test:watch": "vitest watch",
1111
"retest:update": "vitest run --update",
1212
"test:update": "yarn build && vitest run --update",
13+
"bench": "vite build && vitest bench",
1314
"dev": "vite build --watch",
1415
"build": "yarn turbo prepublish -F rrweb-snapshot",
1516
"check-types": "tsc --noEmit",

packages/rrweb-snapshot/src/snapshot.ts

Lines changed: 5 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
getInputType,
2626
toLowerCase,
2727
extractFileExtension,
28+
absolutifyURLs,
2829
} from './utils';
2930

3031
let _id = 1;
@@ -53,71 +54,9 @@ function getValidTagName(element: HTMLElement): Lowercase<string> {
5354
return processedTagName;
5455
}
5556

56-
function extractOrigin(url: string): string {
57-
let origin = '';
58-
if (url.indexOf('//') > -1) {
59-
origin = url.split('/').slice(0, 3).join('/');
60-
} else {
61-
origin = url.split('/')[0];
62-
}
63-
origin = origin.split('?')[0];
64-
return origin;
65-
}
66-
6757
let canvasService: HTMLCanvasElement | null;
6858
let canvasCtx: CanvasRenderingContext2D | null;
6959

70-
const URL_IN_CSS_REF = /url\((?:(')([^']*)'|(")(.*?)"|([^)]*))\)/gm;
71-
const URL_PROTOCOL_MATCH = /^(?:[a-z+]+:)?\/\//i;
72-
const URL_WWW_MATCH = /^www\..*/i;
73-
const DATA_URI = /^(data:)([^,]*),(.*)/i;
74-
export function absoluteToStylesheet(
75-
cssText: string | null,
76-
href: string,
77-
): string {
78-
return (cssText || '').replace(
79-
URL_IN_CSS_REF,
80-
(
81-
origin: string,
82-
quote1: string,
83-
path1: string,
84-
quote2: string,
85-
path2: string,
86-
path3: string,
87-
) => {
88-
const filePath = path1 || path2 || path3;
89-
const maybeQuote = quote1 || quote2 || '';
90-
if (!filePath) {
91-
return origin;
92-
}
93-
if (URL_PROTOCOL_MATCH.test(filePath) || URL_WWW_MATCH.test(filePath)) {
94-
return `url(${maybeQuote}${filePath}${maybeQuote})`;
95-
}
96-
if (DATA_URI.test(filePath)) {
97-
return `url(${maybeQuote}${filePath}${maybeQuote})`;
98-
}
99-
if (filePath[0] === '/') {
100-
return `url(${maybeQuote}${
101-
extractOrigin(href) + filePath
102-
}${maybeQuote})`;
103-
}
104-
const stack = href.split('/');
105-
const parts = filePath.split('/');
106-
stack.pop();
107-
for (const part of parts) {
108-
if (part === '.') {
109-
continue;
110-
} else if (part === '..') {
111-
stack.pop();
112-
} else {
113-
stack.push(part);
114-
}
115-
}
116-
return `url(${maybeQuote}${stack.join('/')}${maybeQuote})`;
117-
},
118-
);
119-
}
120-
12160
// eslint-disable-next-line no-control-regex
12261
const SRCSET_NOT_SPACES = /^[^ \t\n\r\u000c]+/; // Don't use \s, to avoid matching non-breaking space
12362
// eslint-disable-next-line no-control-regex
@@ -254,7 +193,7 @@ export function transformAttribute(
254193
} else if (name === 'srcset') {
255194
return getAbsoluteSrcsetString(doc, value);
256195
} else if (name === 'style') {
257-
return absoluteToStylesheet(value, getHref(doc));
196+
return absolutifyURLs(value, getHref(doc));
258197
} else if (tagName === 'object' && name === 'data') {
259198
return absoluteToDoc(doc, value);
260199
}
@@ -584,7 +523,7 @@ function serializeTextNode(
584523
n,
585524
);
586525
}
587-
textContent = absoluteToStylesheet(textContent, getHref(options.doc));
526+
textContent = absolutifyURLs(textContent, getHref(options.doc));
588527
}
589528
if (isScript) {
590529
textContent = 'SCRIPT_PLACEHOLDER';
@@ -664,7 +603,7 @@ function serializeElementNode(
664603
if (cssText) {
665604
delete attributes.rel;
666605
delete attributes.href;
667-
attributes._cssText = absoluteToStylesheet(cssText, stylesheet!.href!);
606+
attributes._cssText = cssText;
668607
}
669608
}
670609
// dynamic stylesheet
@@ -678,7 +617,7 @@ function serializeElementNode(
678617
(n as HTMLStyleElement).sheet as CSSStyleSheet,
679618
);
680619
if (cssText) {
681-
attributes._cssText = absoluteToStylesheet(cssText, getHref(doc));
620+
attributes._cssText = cssText;
682621
}
683622
}
684623
// form fields

packages/rrweb-snapshot/src/utils.ts

Lines changed: 85 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -96,19 +96,21 @@ export function escapeImportStatement(rule: CSSImportRule): string {
9696
export function stringifyStylesheet(s: CSSStyleSheet): string | null {
9797
try {
9898
const rules = s.rules || s.cssRules;
99-
return rules
100-
? fixBrowserCompatibilityIssuesInCSS(
101-
Array.from(rules, stringifyRule).join(''),
102-
)
103-
: null;
99+
if (!rules) {
100+
return null;
101+
}
102+
const stringifiedRules = Array.from(rules, (rule: CSSRule) =>
103+
stringifyRule(rule, s.href),
104+
).join('');
105+
return fixBrowserCompatibilityIssuesInCSS(stringifiedRules);
104106
} catch (error) {
105107
return null;
106108
}
107109
}
108110

109-
export function stringifyRule(rule: CSSRule): string {
110-
let importStringified;
111+
export function stringifyRule(rule: CSSRule, sheetHref: string | null): string {
111112
if (isCSSImportRule(rule)) {
113+
let importStringified;
112114
try {
113115
importStringified =
114116
// for same-origin stylesheets,
@@ -117,15 +119,25 @@ export function stringifyRule(rule: CSSRule): string {
117119
// work around browser issues with the raw string `@import url(...)` statement
118120
escapeImportStatement(rule);
119121
} catch (error) {
120-
// ignore
122+
importStringified = rule.cssText;
123+
}
124+
if (rule.styleSheet.href) {
125+
// url()s within the imported stylesheet are relative to _that_ sheet's href
126+
return absolutifyURLs(importStringified, rule.styleSheet.href);
127+
}
128+
return importStringified;
129+
} else {
130+
let ruleStringified = rule.cssText;
131+
if (isCSSStyleRule(rule) && rule.selectorText.includes(':')) {
132+
// Safari does not escape selectors with : properly
133+
// see https://bugs.webkit.org/show_bug.cgi?id=184604
134+
ruleStringified = fixSafariColons(ruleStringified);
121135
}
122-
} else if (isCSSStyleRule(rule) && rule.selectorText.includes(':')) {
123-
// Safari does not escape selectors with : properly
124-
// see https://bugs.webkit.org/show_bug.cgi?id=184604
125-
return fixSafariColons(rule.cssText);
136+
if (sheetHref) {
137+
return absolutifyURLs(ruleStringified, sheetHref);
138+
}
139+
return ruleStringified;
126140
}
127-
128-
return importStringified || rule.cssText;
129141
}
130142

131143
export function fixSafariColons(cssStringified: string): string {
@@ -351,3 +363,62 @@ export function extractFileExtension(
351363
const match = url.pathname.match(regex);
352364
return match?.[1] ?? null;
353365
}
366+
367+
function extractOrigin(url: string): string {
368+
let origin = '';
369+
if (url.indexOf('//') > -1) {
370+
origin = url.split('/').slice(0, 3).join('/');
371+
} else {
372+
origin = url.split('/')[0];
373+
}
374+
origin = origin.split('?')[0];
375+
return origin;
376+
}
377+
378+
const URL_IN_CSS_REF = /url\((?:(')([^']*)'|(")(.*?)"|([^)]*))\)/gm;
379+
const URL_PROTOCOL_MATCH = /^(?:[a-z+]+:)?\/\//i;
380+
const URL_WWW_MATCH = /^www\..*/i;
381+
const DATA_URI = /^(data:)([^,]*),(.*)/i;
382+
export function absolutifyURLs(cssText: string | null, href: string): string {
383+
return (cssText || '').replace(
384+
URL_IN_CSS_REF,
385+
(
386+
origin: string,
387+
quote1: string,
388+
path1: string,
389+
quote2: string,
390+
path2: string,
391+
path3: string,
392+
) => {
393+
const filePath = path1 || path2 || path3;
394+
const maybeQuote = quote1 || quote2 || '';
395+
if (!filePath) {
396+
return origin;
397+
}
398+
if (URL_PROTOCOL_MATCH.test(filePath) || URL_WWW_MATCH.test(filePath)) {
399+
return `url(${maybeQuote}${filePath}${maybeQuote})`;
400+
}
401+
if (DATA_URI.test(filePath)) {
402+
return `url(${maybeQuote}${filePath}${maybeQuote})`;
403+
}
404+
if (filePath[0] === '/') {
405+
return `url(${maybeQuote}${
406+
extractOrigin(href) + filePath
407+
}${maybeQuote})`;
408+
}
409+
const stack = href.split('/');
410+
const parts = filePath.split('/');
411+
stack.pop();
412+
for (const part of parts) {
413+
if (part === '.') {
414+
continue;
415+
} else if (part === '..') {
416+
stack.pop();
417+
} else {
418+
stack.push(part);
419+
}
420+
}
421+
return `url(${maybeQuote}${stack.join('/')}${maybeQuote})`;
422+
},
423+
);
424+
}

packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ exports[`integration tests > [html file]: with-style-sheet.html 1`] = `
489489
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
490490
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
491491
<title>with style sheet</title>
492-
<style>body { margin: 0px; background: url(\\"http://localhost:3030/a.jpg\\"); border-image: url(\\"data:image/svg+xml;utf8,&lt;svg xmlns=\\\\\\"http://www.w3.org/2000/svg\\\\\\" x=\\\\\\"0px\\\\\\" y=\\\\\\"0px\\\\\\" viewBox=\\\\\\"0 0 256 256\\\\\\"&gt;&lt;g&gt;&lt;g&gt;&lt;polygon points=\\\\\\"79.093,0 48.907,30.187 146.72,128 48.907,225.813 79.093,256 207.093,128\\\\\\"/&gt;&lt;/g&gt;&lt;/g&gt;&lt;/svg&gt;\\") 100% / 1 / 0 stretch; }p { color: red; background: url(\\"http://localhost:3030/css/b.jpg\\"); }body &gt; p { color: yellow; }</style>
492+
<style>body { margin: 0px; background: url(\\"http://localhost:3030/should-be-in-root-folder.jpg\\"); border-image: url(\\"data:image/svg+xml;utf8,&lt;svg xmlns=\\\\\\"http://www.w3.org/2000/svg\\\\\\" x=\\\\\\"0px\\\\\\" y=\\\\\\"0px\\\\\\" viewBox=\\\\\\"0 0 256 256\\\\\\"&gt;&lt;g&gt;&lt;g&gt;&lt;polygon points=\\\\\\"79.093,0 48.907,30.187 146.72,128 48.907,225.813 79.093,256 207.093,128\\\\\\"/&gt;&lt;/g&gt;&lt;/g&gt;&lt;/svg&gt;\\") 100% / 1 / 0 stretch; }p { color: red; background: url(\\"http://localhost:3030/css/should-be-in-css-folder.jpg\\"); }body &gt; p { color: yellow; }</style>
493493
</head><body>
494494
</body></html>"
495495
`;
@@ -500,7 +500,8 @@ exports[`integration tests > [html file]: with-style-sheet-with-import.html 1`]
500500
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
501501
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
502502
<title>with style sheet with import</title>
503-
<style>@import url(\\"//fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&amp;family=Roboto:wght@100;300;400;500;700&amp;display=swap\\\\\\"\\");body { margin: 0px; background: url(\\"http://localhost:3030/a.jpg\\"); border-image: url(\\"data:image/svg+xml;utf8,&lt;svg xmlns=\\\\\\"http://www.w3.org/2000/svg\\\\\\" x=\\\\\\"0px\\\\\\" y=\\\\\\"0px\\\\\\" viewBox=\\\\\\"0 0 256 256\\\\\\"&gt;&lt;g&gt;&lt;g&gt;&lt;polygon points=\\\\\\"79.093,0 48.907,30.187 146.72,128 48.907,225.813 79.093,256 207.093,128\\\\\\"/&gt;&lt;/g&gt;&lt;/g&gt;&lt;/svg&gt;\\") 100% / 1 / 0 stretch; }p { color: red; background: url(\\"http://localhost:3030/css/b.jpg\\"); }body &gt; p { color: yellow; }</style>
503+
<style>@import url(\\"//fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&amp;family=Roboto:wght@100;300;400;500;700&amp;display=swap\\\\\\"\\");body { margin: 0px; background: url(\\"http://localhost:3030/should-be-in-root-folder.jpg\\"); border-image: url(\\"data:image/svg+xml;utf8,&lt;svg xmlns=\\\\\\"http://www.w3.org/2000/svg\\\\\\" x=\\\\\\"0px\\\\\\" y=\\\\\\"0px\\\\\\" viewBox=\\\\\\"0 0 256 256\\\\\\"&gt;&lt;g&gt;&lt;g&gt;&lt;polygon points=\\\\\\"79.093,0 48.907,30.187 146.72,128 48.907,225.813 79.093,256 207.093,128\\\\\\"/&gt;&lt;/g&gt;&lt;/g&gt;&lt;/svg&gt;\\") 100% / 1 / 0 stretch; }p { color: red; background: url(\\"http://localhost:3030/css/should-be-in-css-folder.jpg\\"); }body &gt; p { color: yellow; }body { margin: 0px; background: url(\\"http://localhost:3030/should-be-in-root-folder.jpg\\"); border-image: url(\\"data:image/svg+xml;utf8,&lt;svg xmlns=\\\\\\"http://www.w3.org/2000/svg\\\\\\" x=\\\\\\"0px\\\\\\" y=\\\\\\"0px\\\\\\" viewBox=\\\\\\"0 0 256 256\\\\\\"&gt;&lt;g&gt;&lt;g&gt;&lt;polygon points=\\\\\\"79.093,0 48.907,30.187 146.72,128 48.907,225.813 79.093,256 207.093,128\\\\\\"/&gt;&lt;/g&gt;&lt;/g&gt;&lt;/svg&gt;\\") 100% / 1 / 0 stretch; }p { color: red; background: url(\\"http://localhost:3030/alt-css/should-be-in-alt-css-folder.jpg\\"); }body &gt; p { color: yellow; }</style>
504+
<style>body { margin: 0px; background: url(\\"http://localhost:3030/should-be-in-root-folder.jpg\\"); border-image: url(\\"data:image/svg+xml;utf8,&lt;svg xmlns=\\\\\\"http://www.w3.org/2000/svg\\\\\\" x=\\\\\\"0px\\\\\\" y=\\\\\\"0px\\\\\\" viewBox=\\\\\\"0 0 256 256\\\\\\"&gt;&lt;g&gt;&lt;g&gt;&lt;polygon points=\\\\\\"79.093,0 48.907,30.187 146.72,128 48.907,225.813 79.093,256 207.093,128\\\\\\"/&gt;&lt;/g&gt;&lt;/g&gt;&lt;/svg&gt;\\") 100% / 1 / 0 stretch; }p { color: red; background: url(\\"http://localhost:3030/alt-css/should-be-in-alt-css-folder.jpg\\"); }body &gt; p { color: yellow; }section { background: url(\\"http://localhost:3030/should-be-in-root-folder.jpg\\"); }</style>
504505
</head><body>
505506
</body></html>"
506507
`;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
body {
2+
margin: 0;
3+
background: url('../should-be-in-root-folder.jpg');
4+
border-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 256 256"><g><g><polygon points="79.093,0 48.907,30.187 146.72,128 48.907,225.813 79.093,256 207.093,128"/></g></g></svg>');
5+
}
6+
p {
7+
color: red;
8+
background: url('./should-be-in-alt-css-folder.jpg');
9+
}
10+
body > p {
11+
color: yellow;
12+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
@import '//fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&family=Roboto:wght@100;300;400;500;700&display=swap"';
22
@import './style.css';
3+
@import '../alt-css/alt-style.css';

packages/rrweb-snapshot/test/css/style.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
body {
22
margin: 0;
3-
background: url('../a.jpg');
3+
background: url('../should-be-in-root-folder.jpg');
44
border-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 256 256"><g><g><polygon points="79.093,0 48.907,30.187 146.72,128 48.907,225.813 79.093,256 207.093,128"/></g></g></svg>');
55
}
66
p {
77
color: red;
8-
background: url('./b.jpg');
8+
background: url('./should-be-in-css-folder.jpg');
99
}
1010
body > p {
1111
color: yellow;

packages/rrweb-snapshot/test/html/with-style-sheet-with-import.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
<meta http-equiv="X-UA-Compatible" content="ie=edge">
88
<title>with style sheet with import</title>
99
<link rel="stylesheet" href="/css/style-with-import.css">
10+
<style>
11+
@import '../alt-css/alt-style.css';
12+
section { background: url('./should-be-in-root-folder.jpg'); }
13+
</style>
1014
</head>
1115

1216
<body>

0 commit comments

Comments
 (0)