tracker: test shorthand expander

This commit is contained in:
nick-delirium 2025-05-16 10:48:23 +02:00
parent 1fba55d2d7
commit cd8085ddcb
No known key found for this signature in database
GPG key ID: 93ABD695DF5FDBA0
2 changed files with 208 additions and 112 deletions

View file

@ -1,4 +1,4 @@
let fakeIdHolder = 1000000 * 99;
let fakeIdHolder = 1000000 * 99
export function inlineRemoteCss(
node: HTMLLinkElement,
@ -11,20 +11,20 @@ export function inlineRemoteCss(
sendPlain?: boolean,
onPlain?: (cssText: string, id: number) => void,
) {
const sheetId = sendPlain ? null : getNextID();
const sheetId = sendPlain ? null : getNextID()
if (!sendPlain) {
addOwner(sheetId!, id);
addOwner(sheetId!, id)
}
const sheet = node.sheet;
const sheet = node.sheet
if (sheet && !forceFetch) {
try {
const cssText = stringifyStylesheet(sheet);
const cssText = stringifyStylesheet(sheet)
if (cssText) {
processCssText(cssText);
return;
processCssText(cssText)
return
}
} catch (e) {
// console.warn("Could not stringify sheet, falling back to fetch:", e);
@ -34,155 +34,152 @@ export function inlineRemoteCss(
// Fall back to fetching if we couldn't get or stringify the sheet
if (node.href) {
fetch(node.href)
.then(response => {
.then((response) => {
if (!response.ok) {
throw new Error(`response status ${response.status}`);
throw new Error(`response status ${response.status}`)
}
return response.text();
return response.text()
})
.then(cssText => {
.then((cssText) => {
if (sendPlain && onPlain) {
onPlain(cssText, fakeIdHolder++);
onPlain(cssText, fakeIdHolder++)
} else {
processCssText(cssText);
processCssText(cssText)
}
})
.catch(error => {
console.error(`OpenReplay: Failed to fetch CSS from ${node.href}:`, error);
});
.catch((error) => {
console.error(`OpenReplay: Failed to fetch CSS from ${node.href}:`, error)
})
}
function processCssText(cssText: string) {
// Remove comments
cssText = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
cssText = cssText.replace(/\/\*[\s\S]*?\*\//g, '')
// Parse and process the CSS text to extract rules
const ruleTexts = parseCSS(cssText);
const ruleTexts = parseCSS(cssText)
for (let i = 0; i < ruleTexts.length; i++) {
insertRule(sheetId!, ruleTexts[i], i, baseHref);
const expandedRule = expandShorthand(ruleTexts[i]).replace(';;', ';')
insertRule(sheetId!, expandedRule, i, baseHref)
}
}
function parseCSS(cssText: string): string[] {
const rules: string[] = [];
let inComment = false;
let inString = false;
let stringChar = '';
let braceLevel = 0;
let currentRule = '';
const rules: string[] = []
let inComment = false
let inString = false
let stringChar = ''
let braceLevel = 0
let currentRule = ''
for (let i = 0; i < cssText.length; i++) {
const char = cssText[i];
const nextChar = cssText[i + 1] || '';
const char = cssText[i]
const nextChar = cssText[i + 1] || ''
// comments
if (!inString && char === '/' && nextChar === '*') {
inComment = true;
i++; // Skip the next character
continue;
inComment = true
i++ // Skip the next character
continue
}
if (inComment) {
if (char === '*' && nextChar === '/') {
inComment = false;
i++; // Skip the next character
inComment = false
i++ // Skip the next character
}
continue;
continue
}
if (!inString && (char === '"' || char === "'")) {
inString = true;
stringChar = char;
currentRule += char;
continue;
inString = true
stringChar = char
currentRule += char
continue
}
if (inString) {
currentRule += char;
currentRule += char
if (char === stringChar && cssText[i - 1] !== '\\') {
inString = false;
inString = false
}
continue;
continue
}
currentRule += char;
currentRule += char
if (char === '{') {
braceLevel++;
braceLevel++
} else if (char === '}') {
braceLevel--;
braceLevel--
if (braceLevel === 0) {
// End of a top-level rule
rules.push(currentRule.trim());
currentRule = '';
rules.push(currentRule.trim())
currentRule = ''
}
}
}
// Handle any remaining text (should be rare)
if (currentRule.trim()) {
rules.push(currentRule.trim());
rules.push(currentRule.trim())
}
return rules;
return rules
}
function stringifyStylesheet(s: CSSStyleSheet): string | null {
try {
const rules = s.rules || s.cssRules;
const rules = s.rules || s.cssRules
if (!rules) {
return null;
return null
}
let sheetHref = s.href;
let sheetHref = s.href
if (!sheetHref && s.ownerNode && (s.ownerNode as HTMLElement).ownerDocument) {
// an inline <style> element
sheetHref = (s.ownerNode as HTMLElement).ownerDocument.location.href;
sheetHref = (s.ownerNode as HTMLElement).ownerDocument.location.href
}
const stringifiedRules = Array.from(rules, (rule: CSSRule) =>
stringifyRule(rule, sheetHref)
).join('');
stringifyRule(rule, sheetHref),
).join('')
return fixBrowserCompatibilityIssuesInCSS(stringifiedRules);
return fixBrowserCompatibilityIssuesInCSS(stringifiedRules)
} catch (error) {
return null;
return null
}
}
function stringifyRule(rule: CSSRule, sheetHref: string | null): string {
if (isCSSImportRule(rule)) {
let importStringified;
let importStringified
try {
importStringified =
// for same-origin stylesheets,
// we can access the imported stylesheet rules directly
stringifyStylesheet((rule as any).styleSheet) ||
// work around browser issues with the raw string `@import url(...)` statement
escapeImportStatement(rule as any);
escapeImportStatement(rule as any)
} catch (error) {
importStringified = rule.cssText;
importStringified = rule.cssText
}
if ((rule as any).styleSheet.href) {
// url()s within the imported stylesheet are relative to _that_ sheet's href
return absolutifyURLs(importStringified, (rule as any).styleSheet.href);
return absolutifyURLs(importStringified, (rule as any).styleSheet.href)
}
return importStringified;
return importStringified
} else {
let ruleStringified = rule.cssText;
let ruleStringified = rule.cssText
if (isCSSStyleRule(rule) && (rule as any).selectorText.includes(':')) {
// Safari does not escape selectors with : properly
ruleStringified = fixSafariColons(ruleStringified);
ruleStringified = fixSafariColons(ruleStringified)
}
if (sheetHref) {
return absolutifyURLs(ruleStringified, sheetHref);
return absolutifyURLs(ruleStringified, sheetHref)
}
return ruleStringified;
return ruleStringified
}
}
function fixBrowserCompatibilityIssuesInCSS(cssText: string): string {
@ -194,50 +191,50 @@ export function inlineRemoteCss(
cssText = cssText.replace(
/\sbackground-clip:\s*text;/g,
' -webkit-background-clip: text; background-clip: text;',
);
)
}
return cssText;
return cssText
}
function escapeImportStatement(rule: any): string {
const { cssText } = rule;
if (cssText.split('"').length < 3) return cssText;
const { cssText } = rule
if (cssText.split('"').length < 3) return cssText
const statement = ['@import', `url(${JSON.stringify(rule.href)})`];
const statement = ['@import', `url(${JSON.stringify(rule.href)})`]
if (rule.layerName === '') {
statement.push(`layer`);
statement.push(`layer`)
} else if (rule.layerName) {
statement.push(`layer(${rule.layerName})`);
statement.push(`layer(${rule.layerName})`)
}
if (rule.supportsText) {
statement.push(`supports(${rule.supportsText})`);
statement.push(`supports(${rule.supportsText})`)
}
if (rule.media.length) {
statement.push(rule.media.mediaText);
statement.push(rule.media.mediaText)
}
return statement.join(' ') + ';';
return statement.join(' ') + ';'
}
function fixSafariColons(cssStringified: string): string {
const regex = /(\[(?:[\w-]+)[^\\])(:(?:[\w-]+)\])/gm;
return cssStringified.replace(regex, '$1\\$2');
const regex = /(\[(?:[\w-]+)[^\\])(:(?:[\w-]+)\])/gm
return cssStringified.replace(regex, '$1\\$2')
}
function isCSSImportRule(rule: CSSRule): boolean {
return 'styleSheet' in rule;
return 'styleSheet' in rule
}
function isCSSStyleRule(rule: CSSRule): boolean {
return 'selectorText' in rule;
return 'selectorText' in rule
}
function absolutifyURLs(cssText: string | null, href: string): string {
if (!cssText) return '';
if (!cssText) return ''
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;
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
return cssText.replace(
URL_IN_CSS_REF,
@ -249,47 +246,146 @@ export function inlineRemoteCss(
path2: string,
path3: string,
) => {
const filePath = path1 || path2 || path3;
const maybeQuote = quote1 || quote2 || '';
const filePath = path1 || path2 || path3
const maybeQuote = quote1 || quote2 || ''
if (!filePath) {
return origin;
return origin
}
if (URL_PROTOCOL_MATCH.test(filePath) || URL_WWW_MATCH.test(filePath)) {
return `url(${maybeQuote}${filePath}${maybeQuote})`;
return `url(${maybeQuote}${filePath}${maybeQuote})`
}
if (DATA_URI.test(filePath)) {
return `url(${maybeQuote}${filePath}${maybeQuote})`;
return `url(${maybeQuote}${filePath}${maybeQuote})`
}
if (filePath[0] === '/') {
return `url(${maybeQuote}${
extractOrigin(href) + filePath
}${maybeQuote})`;
return `url(${maybeQuote}${extractOrigin(href) + filePath}${maybeQuote})`
}
const stack = href.split('/');
const parts = filePath.split('/');
stack.pop();
const stack = href.split('/')
const parts = filePath.split('/')
stack.pop()
for (const part of parts) {
if (part === '.') {
continue;
continue
} else if (part === '..') {
stack.pop();
stack.pop()
} else {
stack.push(part);
stack.push(part)
}
}
return `url(${maybeQuote}${stack.join('/')}${maybeQuote})`;
return `url(${maybeQuote}${stack.join('/')}${maybeQuote})`
},
);
)
}
function extractOrigin(url: string): string {
let origin = '';
let origin = ''
if (url.indexOf('//') > -1) {
origin = url.split('/').slice(0, 3).join('/');
origin = url.split('/').slice(0, 3).join('/')
} else {
origin = url.split('/')[0];
origin = url.split('/')[0]
}
origin = origin.split('?')[0];
return origin;
origin = origin.split('?')[0]
return origin
}
}
const shorthandMap: Record<string, string[]> = {
background: [
'background-color',
'background-image',
'background-repeat',
'background-attachment',
'background-position',
'background-size',
'background-origin',
'background-clip',
],
margin: ['margin-top', 'margin-right', 'margin-bottom', 'margin-left'],
padding: ['padding-top', 'padding-right', 'padding-bottom', 'padding-left'],
border: ['border-width', 'border-style', 'border-color'],
'border-width': [
'border-top-width',
'border-right-width',
'border-bottom-width',
'border-left-width',
],
'border-style': [
'border-top-style',
'border-right-style',
'border-bottom-style',
'border-left-style',
],
'border-color': [
'border-top-color',
'border-right-color',
'border-bottom-color',
'border-left-color',
],
font: ['font-style', 'font-variant', 'font-weight', 'font-size', 'line-height', 'font-family'],
flex: ['flex-grow', 'flex-shrink', 'flex-basis'],
transition: [
'transition-property',
'transition-duration',
'transition-timing-function',
'transition-delay',
],
animation: [
'animation-name',
'animation-duration',
'animation-timing-function',
'animation-delay',
'animation-iteration-count',
'animation-direction',
'animation-fill-mode',
'animation-play-state',
],
'text-decoration': ['text-decoration-line', 'text-decoration-style', 'text-decoration-color'],
'list-style': ['list-style-type', 'list-style-position', 'list-style-image'],
outline: ['outline-width', 'outline-style', 'outline-color'],
}
const defaultValues: Record<string, string> = {
'background-color': 'transparent',
'background-image': 'none',
'background-repeat': 'repeat',
'background-attachment': 'scroll',
'background-position': '0% 0%',
'background-size': 'auto',
'background-origin': 'padding-box',
'background-clip': 'border-box',
}
const expandShorthand = (declaration: string): string => {
for (const [shorthand, longhandProps] of Object.entries(shorthandMap)) {
const regex = new RegExp(`${shorthand}\\s*:\\s*([^;]+)`, 'g')
declaration = declaration.replace(regex, (match, value) => {
if (
shorthand === 'background' &&
(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value.trim()) ||
/^(rgb|rgba|hsl|hsla)\(/.test(value.trim()) ||
/^[a-z]+$/.test(value.trim()))
) {
return `background-color: ${value}; ${longhandProps
.slice(1)
.map((prop) => `${prop}: ${defaultValues[prop] || 'initial'}`)
.join('; ')}`
}
if (shorthand === 'margin' || shorthand === 'padding') {
const parts = value.trim().split(/\s+/)
if (parts.length === 1) {
return `${longhandProps[0]}: ${parts[0]}; ${longhandProps[1]}: ${parts[0]}; ${longhandProps[2]}: ${parts[0]}; ${longhandProps[3]}: ${parts[0]};`
} else if (parts.length === 2) {
return `${longhandProps[0]}: ${parts[0]}; ${longhandProps[1]}: ${parts[1]}; ${longhandProps[2]}: ${parts[0]}; ${longhandProps[3]}: ${parts[1]};`
} else if (parts.length === 3) {
return `${longhandProps[0]}: ${parts[0]}; ${longhandProps[1]}: ${parts[1]}; ${longhandProps[2]}: ${parts[2]}; ${longhandProps[3]}: ${parts[1]};`
} else if (parts.length === 4) {
return `${longhandProps[0]}: ${parts[0]}; ${longhandProps[1]}: ${parts[1]}; ${longhandProps[2]}: ${parts[2]}; ${longhandProps[3]}: ${parts[3]};`
}
}
return longhandProps.map((prop) => `${prop}: initial`).join('; ')
})
}
return declaration
}

View file

@ -131,7 +131,7 @@ describe('inlineRemoteCss', () => {
123, 'body { color: red; }', 0, 'http://example.com'
);
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(
123, '.class { background: blue; }', 1, 'http://example.com'
123, '.class { background-color: blue; background-image: none; background-repeat: repeat; background-attachment: scroll; background-position: 0% 0%; background-size: auto; background-origin: padding-box; background-clip: border-box; }', 1, 'http://example.com'
);
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(
123, '@media (max-width: 600px) { body { font-size: 14px; } }', 2, 'http://example.com'
@ -161,7 +161,7 @@ describe('inlineRemoteCss', () => {
123, 'body { color: red; }', 0, 'http://example.com'
);
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(
123, '.class { background: blue; }', 1, 'http://example.com'
123, '.class { background-color: blue; background-image: none; background-repeat: repeat; background-attachment: scroll; background-position: 0% 0%; background-size: auto; background-origin: padding-box; background-clip: border-box; }', 1, 'http://example.com'
);
});
@ -235,9 +235,9 @@ describe('inlineRemoteCss', () => {
);
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(
123,
'.container {\n padding: 20px;\n }',
'.container {\n padding-top: 20px; padding-right: 20px; padding-bottom: 20px; padding-left: 20px;\n }',
1,
'http://example.com'
);
});
});
});