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>
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
getInputType,
|
||||
toLowerCase,
|
||||
extractFileExtension,
|
||||
absolutifyURLs,
|
||||
} from './utils';
|
||||
|
||||
let _id = 1;
|
||||
@@ -53,71 +54,9 @@ function getValidTagName(element: HTMLElement): Lowercase<string> {
|
||||
return processedTagName;
|
||||
}
|
||||
|
||||
function extractOrigin(url: string): string {
|
||||
let origin = '';
|
||||
if (url.indexOf('//') > -1) {
|
||||
origin = url.split('/').slice(0, 3).join('/');
|
||||
} else {
|
||||
origin = url.split('/')[0];
|
||||
}
|
||||
origin = origin.split('?')[0];
|
||||
return origin;
|
||||
}
|
||||
|
||||
let canvasService: HTMLCanvasElement | null;
|
||||
let canvasCtx: CanvasRenderingContext2D | null;
|
||||
|
||||
const URL_IN_CSS_REF = /url\((?:(')([^']*)'|(")(.*?)"|([^)]*))\)/gm;
|
||||
const URL_PROTOCOL_MATCH = /^(?:[a-z+]+:)?\/\//i;
|
||||
const URL_WWW_MATCH = /^www\..*/i;
|
||||
const DATA_URI = /^(data:)([^,]*),(.*)/i;
|
||||
export function absoluteToStylesheet(
|
||||
cssText: string | null,
|
||||
href: string,
|
||||
): string {
|
||||
return (cssText || '').replace(
|
||||
URL_IN_CSS_REF,
|
||||
(
|
||||
origin: string,
|
||||
quote1: string,
|
||||
path1: string,
|
||||
quote2: string,
|
||||
path2: string,
|
||||
path3: string,
|
||||
) => {
|
||||
const filePath = path1 || path2 || path3;
|
||||
const maybeQuote = quote1 || quote2 || '';
|
||||
if (!filePath) {
|
||||
return origin;
|
||||
}
|
||||
if (URL_PROTOCOL_MATCH.test(filePath) || URL_WWW_MATCH.test(filePath)) {
|
||||
return `url(${maybeQuote}${filePath}${maybeQuote})`;
|
||||
}
|
||||
if (DATA_URI.test(filePath)) {
|
||||
return `url(${maybeQuote}${filePath}${maybeQuote})`;
|
||||
}
|
||||
if (filePath[0] === '/') {
|
||||
return `url(${maybeQuote}${
|
||||
extractOrigin(href) + filePath
|
||||
}${maybeQuote})`;
|
||||
}
|
||||
const stack = href.split('/');
|
||||
const parts = filePath.split('/');
|
||||
stack.pop();
|
||||
for (const part of parts) {
|
||||
if (part === '.') {
|
||||
continue;
|
||||
} else if (part === '..') {
|
||||
stack.pop();
|
||||
} else {
|
||||
stack.push(part);
|
||||
}
|
||||
}
|
||||
return `url(${maybeQuote}${stack.join('/')}${maybeQuote})`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const SRCSET_NOT_SPACES = /^[^ \t\n\r\u000c]+/; // Don't use \s, to avoid matching non-breaking space
|
||||
// eslint-disable-next-line no-control-regex
|
||||
@@ -254,7 +193,7 @@ export function transformAttribute(
|
||||
} else if (name === 'srcset') {
|
||||
return getAbsoluteSrcsetString(doc, value);
|
||||
} else if (name === 'style') {
|
||||
return absoluteToStylesheet(value, getHref(doc));
|
||||
return absolutifyURLs(value, getHref(doc));
|
||||
} else if (tagName === 'object' && name === 'data') {
|
||||
return absoluteToDoc(doc, value);
|
||||
}
|
||||
@@ -584,7 +523,7 @@ function serializeTextNode(
|
||||
n,
|
||||
);
|
||||
}
|
||||
textContent = absoluteToStylesheet(textContent, getHref(options.doc));
|
||||
textContent = absolutifyURLs(textContent, getHref(options.doc));
|
||||
}
|
||||
if (isScript) {
|
||||
textContent = 'SCRIPT_PLACEHOLDER';
|
||||
@@ -664,7 +603,7 @@ function serializeElementNode(
|
||||
if (cssText) {
|
||||
delete attributes.rel;
|
||||
delete attributes.href;
|
||||
attributes._cssText = absoluteToStylesheet(cssText, stylesheet!.href!);
|
||||
attributes._cssText = cssText;
|
||||
}
|
||||
}
|
||||
// dynamic stylesheet
|
||||
@@ -678,7 +617,7 @@ function serializeElementNode(
|
||||
(n as HTMLStyleElement).sheet as CSSStyleSheet,
|
||||
);
|
||||
if (cssText) {
|
||||
attributes._cssText = absoluteToStylesheet(cssText, getHref(doc));
|
||||
attributes._cssText = cssText;
|
||||
}
|
||||
}
|
||||
// form fields
|
||||
|
||||
@@ -96,19 +96,21 @@ export function escapeImportStatement(rule: CSSImportRule): string {
|
||||
export function stringifyStylesheet(s: CSSStyleSheet): string | null {
|
||||
try {
|
||||
const rules = s.rules || s.cssRules;
|
||||
return rules
|
||||
? fixBrowserCompatibilityIssuesInCSS(
|
||||
Array.from(rules, stringifyRule).join(''),
|
||||
)
|
||||
: null;
|
||||
if (!rules) {
|
||||
return null;
|
||||
}
|
||||
const stringifiedRules = Array.from(rules, (rule: CSSRule) =>
|
||||
stringifyRule(rule, s.href),
|
||||
).join('');
|
||||
return fixBrowserCompatibilityIssuesInCSS(stringifiedRules);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function stringifyRule(rule: CSSRule): string {
|
||||
let importStringified;
|
||||
export function stringifyRule(rule: CSSRule, sheetHref: string | null): string {
|
||||
if (isCSSImportRule(rule)) {
|
||||
let importStringified;
|
||||
try {
|
||||
importStringified =
|
||||
// for same-origin stylesheets,
|
||||
@@ -117,15 +119,25 @@ export function stringifyRule(rule: CSSRule): string {
|
||||
// work around browser issues with the raw string `@import url(...)` statement
|
||||
escapeImportStatement(rule);
|
||||
} catch (error) {
|
||||
// ignore
|
||||
importStringified = rule.cssText;
|
||||
}
|
||||
} else if (isCSSStyleRule(rule) && rule.selectorText.includes(':')) {
|
||||
// Safari does not escape selectors with : properly
|
||||
// see https://bugs.webkit.org/show_bug.cgi?id=184604
|
||||
return fixSafariColons(rule.cssText);
|
||||
if (rule.styleSheet.href) {
|
||||
// url()s within the imported stylesheet are relative to _that_ sheet's href
|
||||
return absolutifyURLs(importStringified, rule.styleSheet.href);
|
||||
}
|
||||
return importStringified;
|
||||
} else {
|
||||
let ruleStringified = rule.cssText;
|
||||
if (isCSSStyleRule(rule) && rule.selectorText.includes(':')) {
|
||||
// Safari does not escape selectors with : properly
|
||||
// see https://bugs.webkit.org/show_bug.cgi?id=184604
|
||||
ruleStringified = fixSafariColons(ruleStringified);
|
||||
}
|
||||
if (sheetHref) {
|
||||
return absolutifyURLs(ruleStringified, sheetHref);
|
||||
}
|
||||
return ruleStringified;
|
||||
}
|
||||
|
||||
return importStringified || rule.cssText;
|
||||
}
|
||||
|
||||
export function fixSafariColons(cssStringified: string): string {
|
||||
@@ -351,3 +363,62 @@ export function extractFileExtension(
|
||||
const match = url.pathname.match(regex);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function extractOrigin(url: string): string {
|
||||
let origin = '';
|
||||
if (url.indexOf('//') > -1) {
|
||||
origin = url.split('/').slice(0, 3).join('/');
|
||||
} else {
|
||||
origin = url.split('/')[0];
|
||||
}
|
||||
origin = origin.split('?')[0];
|
||||
return origin;
|
||||
}
|
||||
|
||||
const URL_IN_CSS_REF = /url\((?:(')([^']*)'|(")(.*?)"|([^)]*))\)/gm;
|
||||
const URL_PROTOCOL_MATCH = /^(?:[a-z+]+:)?\/\//i;
|
||||
const URL_WWW_MATCH = /^www\..*/i;
|
||||
const DATA_URI = /^(data:)([^,]*),(.*)/i;
|
||||
export function absolutifyURLs(cssText: string | null, href: string): string {
|
||||
return (cssText || '').replace(
|
||||
URL_IN_CSS_REF,
|
||||
(
|
||||
origin: string,
|
||||
quote1: string,
|
||||
path1: string,
|
||||
quote2: string,
|
||||
path2: string,
|
||||
path3: string,
|
||||
) => {
|
||||
const filePath = path1 || path2 || path3;
|
||||
const maybeQuote = quote1 || quote2 || '';
|
||||
if (!filePath) {
|
||||
return origin;
|
||||
}
|
||||
if (URL_PROTOCOL_MATCH.test(filePath) || URL_WWW_MATCH.test(filePath)) {
|
||||
return `url(${maybeQuote}${filePath}${maybeQuote})`;
|
||||
}
|
||||
if (DATA_URI.test(filePath)) {
|
||||
return `url(${maybeQuote}${filePath}${maybeQuote})`;
|
||||
}
|
||||
if (filePath[0] === '/') {
|
||||
return `url(${maybeQuote}${
|
||||
extractOrigin(href) + filePath
|
||||
}${maybeQuote})`;
|
||||
}
|
||||
const stack = href.split('/');
|
||||
const parts = filePath.split('/');
|
||||
stack.pop();
|
||||
for (const part of parts) {
|
||||
if (part === '.') {
|
||||
continue;
|
||||
} else if (part === '..') {
|
||||
stack.pop();
|
||||
} else {
|
||||
stack.push(part);
|
||||
}
|
||||
}
|
||||
return `url(${maybeQuote}${stack.join('/')}${maybeQuote})`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user