diff --git a/packages/rrweb-snapshot/package.json b/packages/rrweb-snapshot/package.json index 3f54237622..429409145a 100644 --- a/packages/rrweb-snapshot/package.json +++ b/packages/rrweb-snapshot/package.json @@ -57,6 +57,10 @@ "rollup-plugin-typescript2": "^0.31.2", "ts-jest": "^27.0.5", "ts-node": "^7.0.1", - "tslib": "^1.9.3" + "tslib": "^1.9.3", + "@types/postcss-js": "^4.0.4" + }, + "dependencies": { + "postcss": "^8.4.38" } } diff --git a/packages/rrweb-snapshot/src/css.ts b/packages/rrweb-snapshot/src/css.ts index 1a3157d40f..bc3dfb1435 100644 --- a/packages/rrweb-snapshot/src/css.ts +++ b/packages/rrweb-snapshot/src/css.ts @@ -1,965 +1,90 @@ -/** - * This file is a fork of https://github.com/reworkcss/css/blob/master/lib/parse/index.js - * I fork it because: - * 1. The css library was built for node.js which does not have tree-shaking supports. - * 2. Rewrites into typescript give us a better type interface. - */ -/* eslint-disable tsdoc/syntax */ +import type { Plugin, Rule } from 'postcss'; -export interface ParserOptions { - /** Silently fail on parse errors */ - silent?: boolean; - /** - * The path to the file containing css. - * Makes errors and source maps more helpful, by letting them know where code comes from. - */ - source?: string; -} - -/** - * Error thrown during parsing. - */ -export interface ParserError { - /** The full error message with the source position. */ - message?: string; - /** The error message without position. */ - reason?: string; - /** The value of options.source if passed to css.parse. Otherwise undefined. */ - filename?: string; - line?: number; - column?: number; - /** The portion of code that couldn't be parsed. */ - source?: string; -} - -export interface Loc { - line?: number; - column?: number; -} - -/** - * Base AST Tree Node. - */ -export interface Node { - /** The possible values are the ones listed in the Types section on https://github.com/reworkcss/css page. */ - type?: string; - /** A reference to the parent node, or null if the node has no parent. */ - parent?: Node; - /** Information about the position in the source string that corresponds to the node. */ - position?: { - start?: Loc; - end?: Loc; - /** The value of options.source if passed to css.parse. Otherwise undefined. */ - source?: string; - /** The full source string passed to css.parse. */ - content?: string; - }; -} - -export interface NodeWithRules extends Node { - /** Array of nodes with the types rule, comment and any of the at-rule types. */ - rules: Array; -} - -export interface Rule extends Node { - /** The list of selectors of the rule, split on commas. Each selector is trimmed from whitespace and comments. */ - selectors?: string[]; - /** Array of nodes with the types declaration and comment. */ - declarations?: Array; -} - -export interface Declaration extends Node { - /** The property name, trimmed from whitespace and comments. May not be empty. */ - property?: string; - /** The value of the property, trimmed from whitespace and comments. Empty values are allowed. */ - value?: string; -} - -/** - * A rule-level or declaration-level comment. Comments inside selectors, properties and values etc. are lost. - */ -export interface Comment extends Node { - comment?: string; -} - -/** - * The @charset at-rule. - */ -export interface Charset extends Node { - /** The part following @charset. */ - charset?: string; -} - -/** - * The @custom-media at-rule - */ -export interface CustomMedia extends Node { - /** The ---prefixed name. */ - name?: string; - /** The part following the name. */ - media?: string; -} - -/** - * The @document at-rule. - */ -export interface Document extends NodeWithRules { - /** The part following @document. */ - document?: string; - /** The vendor prefix in @document, or undefined if there is none. */ - vendor?: string; -} - -/** - * The @font-face at-rule. - */ -export interface FontFace extends Node { - /** Array of nodes with the types declaration and comment. */ - declarations?: Array; -} - -/** - * The @host at-rule. - */ -export type Host = NodeWithRules; - -/** - * The @import at-rule. - */ -export interface Import extends Node { - /** The part following @import. */ - import?: string; -} - -/** - * The @keyframes at-rule. - */ -export interface KeyFrames extends Node { - /** The name of the keyframes rule. */ - name?: string; - /** The vendor prefix in @keyframes, or undefined if there is none. */ - vendor?: string; - /** Array of nodes with the types keyframe and comment. */ - keyframes?: Array; -} - -export interface KeyFrame extends Node { - /** The list of "selectors" of the keyframe rule, split on commas. Each “selector” is trimmed from whitespace. */ - values?: string[]; - /** Array of nodes with the types declaration and comment. */ - declarations?: Array; -} - -/** - * The @media at-rule. - */ -export interface Media extends NodeWithRules { - /** The part following @media. */ - media?: string; -} - -/** - * The @namespace at-rule. - */ -export interface Namespace extends Node { - /** The part following @namespace. */ - namespace?: string; -} - -/** - * The @page at-rule. - */ -export interface Page extends Node { - /** The list of selectors of the rule, split on commas. Each selector is trimmed from whitespace and comments. */ - selectors?: string[]; - /** Array of nodes with the types declaration and comment. */ - declarations?: Array; -} - -/** - * The @supports at-rule. - */ -export interface Supports extends NodeWithRules { - /** The part following @supports. */ - supports?: string; -} - -/** All at-rules. */ -export type AtRule = - | Charset - | CustomMedia - | Document - | FontFace - | Host - | Import - | KeyFrames - | Media - | Namespace - | Page - | Supports; - -/** - * A collection of rules - */ -export interface StyleRules extends NodeWithRules { - source?: string; - /** Array of Errors. Errors collected during parsing when option silent is true. */ - parsingErrors?: ParserError[]; -} - -/** - * The root node returned by css.parse. - */ -export interface Stylesheet extends Node { - stylesheet?: StyleRules; -} - -// http://www.w3.org/TR/CSS21/grammar.html -// https://github.com/visionmedia/css-parse/pull/49#issuecomment-30088027 -const commentre = /\/\*[^*]*\*+([^/*][^*]*\*+)*\//g; - -export function parse(css: string, options: ParserOptions = {}): Stylesheet { - /** - * Positional. - */ - - let lineno = 1; - let column = 1; - - /** - * Update lineno and column based on `str`. - */ - - function updatePosition(str: string) { - const lines = str.match(/\n/g); - if (lines) { - lineno += lines.length; - } - const i = str.lastIndexOf('\n'); - column = i === -1 ? column + str.length : str.length - i; - } - - /** - * Mark position and patch `node.position`. - */ - - function position() { - const start = { line: lineno, column }; - return ( - node: Rule | Declaration | Comment | AtRule | Stylesheet | KeyFrame, - ) => { - node.position = new Position(start); - whitespace(); - return node; - }; - } - - /** - * Store position information for a node - */ - - class Position { - public content!: string; - public start!: Loc; - public end!: Loc; - public source?: string; - - constructor(start: Loc) { - this.start = start; - this.end = { line: lineno, column }; - this.source = options.source; - } - } - - /** - * Non-enumerable source string - */ - - Position.prototype.content = css; - - const errorsList: ParserError[] = []; - - function error(msg: string) { - const err = new Error( - `${options.source || ''}:${lineno}:${column}: ${msg}`, - ) as ParserError; - err.reason = msg; - err.filename = options.source; - err.line = lineno; - err.column = column; - err.source = css; - - if (options.silent) { - errorsList.push(err); - } else { - throw err; - } - } - - /** - * Parse stylesheet. - */ - - function stylesheet(): Stylesheet { - const rulesList = rules(); +const MEDIA_SELECTOR = /(max|min)-device-(width|height)/; +const MEDIA_SELECTOR_GLOBAL = new RegExp(MEDIA_SELECTOR.source, 'g'); +const mediaSelectorPlugin: Plugin = { + postcssPlugin: 'postcss-custom-selectors', + prepare() { return { - type: 'stylesheet', - stylesheet: { - source: options.source, - rules: rulesList, - parsingErrors: errorsList, + postcssPlugin: 'postcss-custom-selectors', + AtRule: function (atrule) { + if (atrule.params.match(MEDIA_SELECTOR_GLOBAL)) { + atrule.params = atrule.params.replace(MEDIA_SELECTOR_GLOBAL, '$1-$2'); + } }, }; - } - - /** - * Opening brace. - */ - - function open() { - return match(/^{\s*/); - } - - /** - * Closing brace. - */ - - function close() { - return match(/^}/); - } - - /** - * Parse ruleset. - */ - - function rules() { - let node: Rule | void; - const rules: Rule[] = []; - whitespace(); - comments(rules); - while (css.length && css.charAt(0) !== '}' && (node = atrule() || rule())) { - if (node) { - rules.push(node); - comments(rules); - } - } - return rules; - } - - /** - * Match `re` and return captures. - */ - - function match(re: RegExp) { - const m = re.exec(css); - if (!m) { - return; - } - const str = m[0]; - updatePosition(str); - css = css.slice(str.length); - return m; - } - - /** - * Parse whitespace. - */ - - function whitespace() { - match(/^\s*/); - } - - /** - * Parse comments; - */ - - function comments(rules: Rule[] = []) { - let c: Comment | void; - while ((c = comment())) { - if (c) { - rules.push(c); - } - c = comment(); - } - return rules; - } - - /** - * Parse comment. - */ - - function comment() { - const pos = position(); - if ('/' !== css.charAt(0) || '*' !== css.charAt(1)) { - return; - } - - let i = 2; - while ( - '' !== css.charAt(i) && - ('*' !== css.charAt(i) || '/' !== css.charAt(i + 1)) - ) { - ++i; - } - i += 2; - - if ('' === css.charAt(i - 1)) { - return error('End of comment missing'); - } - - const str = css.slice(2, i - 2); - column += 2; - updatePosition(str); - css = css.slice(i); - column += 2; - - return pos({ - type: 'comment', - comment: str, - }); - } - - /** - * Parse selector. - */ - - function selector() { - whitespace(); - while (css[0] == '}') { - error('extra closing bracket'); - css = css.slice(1); - whitespace(); - } - - // Use match logic from https://github.com/NxtChg/pieces/blob/3eb39c8287a97632e9347a24f333d52d916bc816/js/css_parser/css_parse.js#L46C1-L47C1 - const m = match(/^(("(?:\\"|[^"])*"|'(?:\\'|[^'])*'|[^{])+)/); - if (!m) { - return; - } - - /* @fix Remove all comments from selectors - * http://ostermiller.org/findcomment.html */ - const cleanedInput = m[0] - .trim() - .replace(/\/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*\/+/g, '') - - // Handle strings by replacing commas inside them - .replace(/"(?:\\"|[^"])*"|'(?:\\'|[^'])*'/g, (m) => { - return m.replace(/,/g, '\u200C'); - }); - - // Split using a custom function and restore commas in strings - return customSplit(cleanedInput).map((s) => - s.replace(/\u200C/g, ',').trim(), - ); - } - - /** - * Split selector correctly, ensuring not to split on comma if inside (). - */ - - function customSplit(input: string) { - const result = []; - let currentSegment = ''; - let depthParentheses = 0; // Track depth of parentheses - let depthBrackets = 0; // Track depth of square brackets - let currentStringChar = null; - - for (const char of input) { - const hasStringEscape = currentSegment.endsWith('\\'); - - if (currentStringChar) { - if (currentStringChar === char && !hasStringEscape) { - currentStringChar = null; + }, +}; + +// Adapted from https://github.com/giuseppeg/postcss-pseudo-classes/blob/master/index.js +const pseudoClassPlugin: Plugin = { + postcssPlugin: 'postcss-hover-classes', + prepare: function () { + const fixed: Rule[] = []; + return { + Rule: function (rule) { + if (fixed.indexOf(rule) !== -1) { + return; } - } else if (char === '(') { - depthParentheses++; - } else if (char === ')') { - depthParentheses--; - } else if (char === '[') { - depthBrackets++; - } else if (char === ']') { - depthBrackets--; - } else if ('\'"'.includes(char)) { - currentStringChar = char; - } - - // Split point is a comma that is not inside parentheses or square brackets - if (char === ',' && depthParentheses === 0 && depthBrackets === 0) { - result.push(currentSegment); - currentSegment = ''; - } else { - currentSegment += char; - } - } - - // Add the last segment - if (currentSegment) { - result.push(currentSegment); - } - - return result; - } - - /** - * Parse declaration. - */ - - function declaration(): Declaration | void | never { - const pos = position(); - - // prop - // eslint-disable-next-line no-useless-escape - const propMatch = match(/^(\*?[-#\/\*\\\w]+(\[[0-9a-z_-]+\])?)\s*/); - if (!propMatch) { - return; - } - const prop = trim(propMatch[0]); - - // : - if (!match(/^:\s*/)) { - return error(`property missing ':'`); - } + fixed.push(rule); - // val - // eslint-disable-next-line no-useless-escape - const val = match(/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^\)]*?\)|[^};])+)/); + rule.selectors.forEach(function (selector) { + if (!selector.includes(':')) { + return; + } - const ret = pos({ - type: 'declaration', - property: prop.replace(commentre, ''), - value: val ? trim(val[0]).replace(commentre, '') : '', - }); + const selectorParts = selector.replace(/\n/g, ' ').split(' '); + const pseudoedSelectorParts: string[] = []; - // ; - match(/^[;\s]*/); + selectorParts.forEach(function (selectorPart) { + const pseudos = selectorPart.match(/::?([^:]+)/g); - return ret; - } + if (!pseudos) { + pseudoedSelectorParts.push(selectorPart); + return; + } - /** - * Parse declarations. - */ + const baseSelector = selectorPart.substr( + 0, + selectorPart.length - pseudos.join('').length, + ); - function declarations() { - const decls: Array = []; + const classPseudos = pseudos.map(function (pseudo) { + const pseudoToCheck = pseudo.replace(/\(.*/g, ''); + if (pseudoToCheck !== ':hover') { + return pseudo; + } - if (!open()) { - return error(`missing '{'`); - } - comments(decls); + // Ignore pseudo-elements! + if (pseudo.match(/^::/)) { + return pseudo; + } - // declarations - let decl; - while ((decl = declaration())) { - if ((decl as unknown) !== false) { - decls.push(decl); - comments(decls); - } - decl = declaration(); - } + // Kill the colon + pseudo = pseudo.substr(1); - if (!close()) { - return error(`missing '}'`); - } - return decls; - } + // Replace left and right parens + pseudo = pseudo.replace(/\(/g, '\\('); + pseudo = pseudo.replace(/\)/g, '\\)'); - /** - * Parse keyframe. - */ + return '.' + '\\:' + pseudo; + }); - function keyframe() { - let m; - const vals = []; - const pos = position(); + pseudoedSelectorParts.push(baseSelector + classPseudos.join('')); + }); - while ((m = match(/^((\d+\.\d+|\.\d+|\d+)%?|[a-z]+)\s*/))) { - vals.push(m[1]); - match(/^,\s*/); - } + addSelector(pseudoedSelectorParts.join(' ')); - if (!vals.length) { - return; - } - - return pos({ - type: 'keyframe', - values: vals, - declarations: declarations() as Declaration[], - }); - } - - /** - * Parse keyframes. - */ - - function atkeyframes() { - const pos = position(); - let m = match(/^@([-\w]+)?keyframes\s*/); - - if (!m) { - return; - } - const vendor = m[1]; - - // identifier - m = match(/^([-\w]+)\s*/); - if (!m) { - return error('@keyframes missing name'); - } - const name = m[1]; - - if (!open()) { - return error(`@keyframes missing '{'`); - } - - let frame; - let frames = comments(); - while ((frame = keyframe())) { - frames.push(frame); - frames = frames.concat(comments()); - } - - if (!close()) { - return error(`@keyframes missing '}'`); - } - - return pos({ - type: 'keyframes', - name, - vendor, - keyframes: frames, - }); - } - - /** - * Parse supports. - */ - - function atsupports() { - const pos = position(); - const m = match(/^@supports *([^{]+)/); - - if (!m) { - return; - } - const supports = trim(m[1]); - - if (!open()) { - return error(`@supports missing '{'`); - } - - const style = comments().concat(rules()); - - if (!close()) { - return error(`@supports missing '}'`); - } - - return pos({ - type: 'supports', - supports, - rules: style, - }); - } - - /** - * Parse host. - */ - - function athost() { - const pos = position(); - const m = match(/^@host\s*/); - - if (!m) { - return; - } - - if (!open()) { - return error(`@host missing '{'`); - } - - const style = comments().concat(rules()); - - if (!close()) { - return error(`@host missing '}'`); - } - - return pos({ - type: 'host', - rules: style, - }); - } - - /** - * Parse media. - */ - - function atmedia() { - const pos = position(); - const m = match(/^@media *([^{]+)/); - - if (!m) { - return; - } - const media = trim(m[1]); - - if (!open()) { - return error(`@media missing '{'`); - } - - const style = comments().concat(rules()); - - if (!close()) { - return error(`@media missing '}'`); - } - - return pos({ - type: 'media', - media, - rules: style, - }); - } - - /** - * Parse custom-media. - */ - - function atcustommedia() { - const pos = position(); - const m = match(/^@custom-media\s+(--[^\s]+)\s*([^{;]+);/); - if (!m) { - return; - } - - return pos({ - type: 'custom-media', - name: trim(m[1]), - media: trim(m[2]), - }); - } - - /** - * Parse paged media. - */ - - function atpage() { - const pos = position(); - const m = match(/^@page */); - if (!m) { - return; - } - - const sel = selector() || []; - - if (!open()) { - return error(`@page missing '{'`); - } - let decls = comments(); - - // declarations - let decl; - while ((decl = declaration())) { - decls.push(decl); - decls = decls.concat(comments()); - } - - if (!close()) { - return error(`@page missing '}'`); - } - - return pos({ - type: 'page', - selectors: sel, - declarations: decls, - }); - } - - /** - * Parse document. - */ - - function atdocument() { - const pos = position(); - const m = match(/^@([-\w]+)?document *([^{]+)/); - if (!m) { - return; - } - - const vendor = trim(m[1]); - const doc = trim(m[2]); - - if (!open()) { - return error(`@document missing '{'`); - } - - const style = comments().concat(rules()); - - if (!close()) { - return error(`@document missing '}'`); - } - - return pos({ - type: 'document', - document: doc, - vendor, - rules: style, - }); - } - - /** - * Parse font-face. - */ - - function atfontface() { - const pos = position(); - const m = match(/^@font-face\s*/); - if (!m) { - return; - } - - if (!open()) { - return error(`@font-face missing '{'`); - } - let decls = comments(); - - // declarations - let decl; - while ((decl = declaration())) { - decls.push(decl); - decls = decls.concat(comments()); - } - - if (!close()) { - return error(`@font-face missing '}'`); - } - - return pos({ - type: 'font-face', - declarations: decls, - }); - } - - /** - * Parse import - */ - - const atimport = _compileAtrule('import'); - - /** - * Parse charset - */ - - const atcharset = _compileAtrule('charset'); - - /** - * Parse namespace - */ - - const atnamespace = _compileAtrule('namespace'); - - /** - * Parse non-block at-rules - */ - - function _compileAtrule(name: string) { - const re = new RegExp('^@' + name + '\\s*([^;]+);'); - return () => { - const pos = position(); - const m = match(re); - if (!m) { - return; - } - const ret: Record = { type: name }; - ret[name] = m[1].trim(); - return pos(ret); + function addSelector(newSelector: string) { + if (newSelector && newSelector !== selector) { + rule.selector += ',\n' + newSelector; + } + } + }); + }, }; - } - - /** - * Parse at rule. - */ - - function atrule() { - if (css[0] !== '@') { - return; - } - - return ( - atkeyframes() || - atmedia() || - atcustommedia() || - atsupports() || - atimport() || - atcharset() || - atnamespace() || - atdocument() || - atpage() || - athost() || - atfontface() - ); - } - - /** - * Parse rule. - */ - - function rule() { - const pos = position(); - const sel = selector(); - - if (!sel) { - return error('selector missing'); - } - comments(); - - return pos({ - type: 'rule', - selectors: sel, - declarations: declarations() as Declaration[], - }); - } - - return addParent(stylesheet()); -} - -/** - * Trim `str`. - */ - -function trim(str: string) { - return str ? str.replace(/^\s+|\s+$/g, '') : ''; -} - -/** - * Adds non-enumerable parent node reference to each node. - */ - -function addParent(obj: Stylesheet, parent?: Stylesheet): Stylesheet { - const isNode = obj && typeof obj.type === 'string'; - const childParent = isNode ? obj : parent; - - for (const k of Object.keys(obj)) { - const value = obj[k as keyof Stylesheet]; - if (Array.isArray(value)) { - value.forEach((v) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - addParent(v, childParent); - }); - } else if (value && typeof value === 'object') { - addParent(value as Stylesheet, childParent); - } - } - - if (isNode) { - Object.defineProperty(obj, 'parent', { - configurable: true, - writable: true, - enumerable: false, - value: parent || null, - }); - } + }, +}; - return obj; -} +export { mediaSelectorPlugin, pseudoClassPlugin }; diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 97faedf1da..6691a8766f 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -1,4 +1,4 @@ -import { Rule, Media, NodeWithRules, parse } from './css'; +import { mediaSelectorPlugin, pseudoClassPlugin } from './css'; import { serializedNodeWithId, NodeType, @@ -10,6 +10,9 @@ import { } from './types'; import { isElement, Mirror, isNodeMetaEqual } from './utils'; +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires +const postcss = require('postcss'); + const tagMap: tagMap = { script: 'noscript', // camel case svg element tag names @@ -58,83 +61,16 @@ function getTagName(n: elementNode): string { return tagName; } -// based on https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping -function escapeRegExp(str: string) { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string -} - -const MEDIA_SELECTOR = /(max|min)-device-(width|height)/; -const MEDIA_SELECTOR_GLOBAL = new RegExp(MEDIA_SELECTOR.source, 'g'); -const HOVER_SELECTOR = /([^\\]):hover/; -const HOVER_SELECTOR_GLOBAL = new RegExp(HOVER_SELECTOR.source, 'g'); export function adaptCssForReplay(cssText: string, cache: BuildCache): string { const cachedStyle = cache?.stylesWithHoverClass.get(cssText); if (cachedStyle) return cachedStyle; - const ast = parse(cssText, { - silent: true, - }); - - if (!ast.stylesheet) { - return cssText; - } - - const selectors: string[] = []; - const medias: string[] = []; - function getSelectors(rule: Rule | Media | NodeWithRules) { - if ('selectors' in rule && rule.selectors) { - rule.selectors.forEach((selector: string) => { - if (HOVER_SELECTOR.test(selector)) { - selectors.push(selector); - } - }); - } - if ('media' in rule && rule.media && MEDIA_SELECTOR.test(rule.media)) { - medias.push(rule.media); - } - if ('rules' in rule && rule.rules) { - rule.rules.forEach(getSelectors); - } - } - getSelectors(ast.stylesheet); - - let result = cssText; - if (selectors.length > 0) { - const selectorMatcher = new RegExp( - selectors - .filter((selector, index) => selectors.indexOf(selector) === index) - .sort((a, b) => b.length - a.length) - .map((selector) => { - return escapeRegExp(selector); - }) - .join('|'), - 'g', - ); - result = result.replace(selectorMatcher, (selector) => { - const newSelector = selector.replace( - HOVER_SELECTOR_GLOBAL, - '$1.\\:hover', - ); - return `${selector}, ${newSelector}`; - }); - } - if (medias.length > 0) { - const mediaMatcher = new RegExp( - medias - .filter((media, index) => medias.indexOf(media) === index) - .sort((a, b) => b.length - a.length) - .map((media) => { - return escapeRegExp(media); - }) - .join('|'), - 'g', - ); - result = result.replace(mediaMatcher, (media) => { - // not attempting to maintain min-device-width along with min-width - // (it's non standard) - return media.replace(MEDIA_SELECTOR_GLOBAL, '$1-$2'); - }); - } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + const ast: { css: string } = postcss([ + mediaSelectorPlugin, + pseudoClassPlugin, + ]).process(cssText); + const result = ast.css; cache?.stylesWithHoverClass.set(cssText, result); return result; } diff --git a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap index 77beb3be81..179cf7e8ad 100644 --- a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap @@ -363,6 +363,12 @@ exports[`integration tests [html file]: picture-in-frame.html 1`] = ` " `; +exports[`integration tests [html file]: picture-with-inline-onload.html 1`] = ` +" + \\"This + " +`; + exports[`integration tests [html file]: preload.html 1`] = ` " diff --git a/packages/rrweb-snapshot/test/css.test.ts b/packages/rrweb-snapshot/test/css.test.ts index 6f10f6e569..841a87eaa0 100644 --- a/packages/rrweb-snapshot/test/css.test.ts +++ b/packages/rrweb-snapshot/test/css.test.ts @@ -1,254 +1,83 @@ -import { parse, Rule, Media } from '../src/css'; -import { fixSafariColons, escapeImportStatement } from './../src/utils'; +import { mediaSelectorPlugin, pseudoClassPlugin } from '../src/css'; +import { type Plugin } from 'postcss'; +const postcss = require('postcss'); describe('css parser', () => { - it('should save the filename and source', () => { - const css = 'booty {\n size: large;\n}\n'; - const ast = parse(css, { - source: 'booty.css', + function parse(plugin: Plugin, input: string): string { + const ast = postcss([plugin]).process(input, {}); + return ast.css; + } + + describe('mediaSelectorPlugin', () => { + it('selectors without device remain unchanged', () => { + const cssText = + '@media only screen and (min-width: 1200px) { .a { width: 10px; }}'; + expect(parse(mediaSelectorPlugin, cssText)).toEqual(cssText); }); - expect(ast.stylesheet!.source).toEqual('booty.css'); - - const position = ast.stylesheet!.rules[0].position!; - expect(position.start).toBeTruthy(); - expect(position.end).toBeTruthy(); - expect(position.source).toEqual('booty.css'); - expect(position.content).toEqual(css); - }); - - it('should throw when a selector is missing', () => { - expect(() => { - parse('{size: large}'); - }).toThrow(); - - expect(() => { - parse('b { color: red; }\n{ color: green; }\na { color: blue; }'); - }).toThrow(); - }); - - it('should throw when a broken comment is found', () => { - expect(() => { - parse('thing { color: red; } /* b { color: blue; }'); - }).toThrow(); - - expect(() => { - parse('/*'); - }).toThrow(); - - /* Nested comments should be fine */ - expect(() => { - parse('/* /* */'); - }).not.toThrow(); - }); - - it('should allow empty property value', () => { - expect(() => { - parse('p { color:; }'); - }).not.toThrow(); - }); - - it('should not throw with silent option', () => { - expect(() => { - parse('thing { color: red; } /* b { color: blue; }', { silent: true }); - }).not.toThrow(); - }); - - it('should list the parsing errors and continue parsing', () => { - const result = parse( - 'foo { color= red; } bar { color: blue; } baz {}} boo { display: none}', - { - silent: true, - source: 'foo.css', - }, - ); - - const rules = result.stylesheet!.rules; - expect(rules.length).toBeGreaterThan(2); - - const errors = result.stylesheet!.parsingErrors!; - expect(errors.length).toEqual(2); - - expect(errors[0]).toHaveProperty('message'); - expect(errors[0]).toHaveProperty('reason'); - expect(errors[0]).toHaveProperty('filename'); - expect(errors[0]).toHaveProperty('line'); - expect(errors[0]).toHaveProperty('column'); - expect(errors[0]).toHaveProperty('source'); - expect(errors[0].filename).toEqual('foo.css'); - }); - - it('should parse selector with comma nested inside ()', () => { - const result = parse( - '[_nghost-ng-c4172599085]:not(.fit-content).aim-select:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active) { border-color: rgb(84, 84, 84); }', - ); - - expect(result.parent).toEqual(null); - - const rules = result.stylesheet!.rules; - expect(rules.length).toEqual(1); - - let rule = rules[0] as Rule; - expect(rule.parent).toEqual(result); - expect(rule.selectors?.length).toEqual(1); - - let decl = rule.declarations![0]; - expect(decl.parent).toEqual(rule); - }); - - it('parses { and } in attribute selectors correctly', () => { - const result = parse('foo[someAttr~="{someId}"] { color: red; }'); - const rules = result.stylesheet!.rules; - - expect(rules.length).toEqual(1); - - const rule = rules[0] as Rule; - - expect(rule.selectors![0]).toEqual('foo[someAttr~="{someId}"]'); - }); - - it('should set parent property', () => { - const result = parse( - 'thing { test: value; }\n' + - '@media (min-width: 100px) { thing { test: value; } }', - ); - - expect(result.parent).toEqual(null); - - const rules = result.stylesheet!.rules; - expect(rules.length).toEqual(2); - - let rule = rules[0] as Rule; - expect(rule.parent).toEqual(result); - expect(rule.declarations!.length).toEqual(1); - - let decl = rule.declarations![0]; - expect(decl.parent).toEqual(rule); - - const media = rules[1] as Media; - expect(media.parent).toEqual(result); - expect(media.rules!.length).toEqual(1); - - rule = media.rules![0] as Rule; - expect(rule.parent).toEqual(media); - - expect(rule.declarations!.length).toEqual(1); - decl = rule.declarations![0]; - expect(decl.parent).toEqual(rule); - }); - - it('parses : in attribute selectors correctly', () => { - const out1 = fixSafariColons('[data-foo] { color: red; }'); - expect(out1).toEqual('[data-foo] { color: red; }'); - - const out2 = fixSafariColons('[data-foo:other] { color: red; }'); - expect(out2).toEqual('[data-foo\\:other] { color: red; }'); - - const out3 = fixSafariColons('[data-aa\\:other] { color: red; }'); - expect(out3).toEqual('[data-aa\\:other] { color: red; }'); - }); - - it('parses nested commas in selectors correctly', () => { - const result = parse( - ` -body > ul :is(li:not(:first-of-type) a:hover, li:not(:first-of-type).active a) { - background: red; -} -`, - ); - expect((result.stylesheet!.rules[0] as Rule)!.selectors!.length).toEqual(1); - - const trickresult = parse( - ` -li[attr="weirdly("] a:hover, li[attr="weirdly)"] a { - background-color: red; -} -`, - ); - expect( - (trickresult.stylesheet!.rules[0] as Rule)!.selectors!.length, - ).toEqual(2); - - const weirderresult = parse( - ` -li[attr="weirder\\"("] a:hover, li[attr="weirder\\")"] a { - background-color: red; -} -`, - ); - expect( - (weirderresult.stylesheet!.rules[0] as Rule)!.selectors!.length, - ).toEqual(2); - - const commainstrresult = parse( - ` -li[attr="has,comma"] a:hover { - background-color: red; -} -`, - ); - expect( - (commainstrresult.stylesheet!.rules[0] as Rule)!.selectors!.length, - ).toEqual(1); + it('can adapt media rules to replay context', () => { + [ + ['min', 'width'], + ['min', 'height'], + ['max', 'width'], + ['max', 'height'], + ].forEach(([first, second]) => { + expect( + parse( + mediaSelectorPlugin, + `@media only screen and (${first}-device-${second}: 1200px) { .a { width: 10px; }}`, + ), + ).toEqual( + `@media only screen and (${first}-${second}: 1200px) { .a { width: 10px; }}`, + ); + }); + expect( + parse( + mediaSelectorPlugin, + '@media only screen and (min-device-width: 1200px) { .a { width: 10px; }}', + ), + ).toEqual( + `@media only screen and (min-width: 1200px) { .a { width: 10px; }}`, + ); + }); }); - it('parses imports with quotes correctly', () => { - const out1 = escapeImportStatement({ - cssText: `@import url("/foo.css;900;800"");`, - href: '/foo.css;900;800"', - media: { - length: 0, - }, - layerName: null, - supportsText: null, - } as unknown as CSSImportRule); - expect(out1).toEqual(`@import url("/foo.css;900;800\\"");`); + describe('pseudoClassPlugin', () => { + it('parses nested commas in selectors correctly', () => { + const cssText = + 'body > ul :is(li:not(:first-of-type) a:hover, li:not(:first-of-type).active a) {background: red;}'; + expect(parse(pseudoClassPlugin, cssText)).toEqual(cssText); + }); - const out2 = escapeImportStatement({ - cssText: `@import url("/foo.css;900;800"") supports(display: flex);`, - href: '/foo.css;900;800"', - media: { - length: 0, - }, - layerName: null, - supportsText: 'display: flex', - } as unknown as CSSImportRule); - expect(out2).toEqual( - `@import url("/foo.css;900;800\\"") supports(display: flex);`, - ); + it('should parse selector with comma nested inside ()', () => { + const cssText = + '[_nghost-ng-c4172599085]:not(.fit-content).aim-select:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active) { border-color: rgb(84, 84, 84); }'; + expect(parse(pseudoClassPlugin, cssText)) + .toEqual(`[_nghost-ng-c4172599085]:not(.fit-content).aim-select:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active), +[_nghost-ng-c4172599085]:not(.fit-content).aim-select.\\:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active) { border-color: rgb(84, 84, 84); }`); + }); - const out3 = escapeImportStatement({ - cssText: `@import url("/foo.css;900;800"");`, - href: '/foo.css;900;800"', - media: { - length: 1, - mediaText: 'print, screen', - }, - layerName: null, - supportsText: null, - } as unknown as CSSImportRule); - expect(out3).toEqual(`@import url("/foo.css;900;800\\"") print, screen;`); + it('ignores ( in strings', () => { + const cssText = + 'li[attr="weirdly("] a:hover, li[attr="weirdly)"] a {background-color: red;}'; + expect(parse(pseudoClassPlugin, cssText)) + .toEqual(`li[attr="weirdly("] a:hover, li[attr="weirdly)"] a, +li[attr="weirdly("] a.\\:hover {background-color: red;}`); + }); - const out4 = escapeImportStatement({ - cssText: `@import url("/foo.css;900;800"") layer(layer-1);`, - href: '/foo.css;900;800"', - media: { - length: 0, - }, - layerName: 'layer-1', - supportsText: null, - } as unknown as CSSImportRule); - expect(out4).toEqual(`@import url("/foo.css;900;800\\"") layer(layer-1);`); + it('ignores escaping in strings', () => { + const cssText = `li[attr="weirder\\"("] a:hover, li[attr="weirder\\")"] a {background-color: red;}`; + expect(parse(pseudoClassPlugin, cssText)) + .toEqual(`li[attr="weirder\\"("] a:hover, li[attr="weirder\\")"] a, +li[attr="weirder\\"("] a.\\:hover {background-color: red;}`); + }); - const out5 = escapeImportStatement({ - cssText: `@import url("/foo.css;900;800"") layer;`, - href: '/foo.css;900;800"', - media: { - length: 0, - }, - layerName: '', - supportsText: null, - } as unknown as CSSImportRule); - expect(out5).toEqual(`@import url("/foo.css;900;800\\"") layer;`); + it('ignores comma in string', () => { + const cssText = 'li[attr="has,comma"] a:hover {background: red;}'; + expect(parse(pseudoClassPlugin, cssText)).toEqual( + `li[attr="has,comma"] a:hover, +li[attr="has,comma"] a.\\:hover {background: red;}`, + ); + }); }); }); diff --git a/packages/rrweb-snapshot/test/rebuild.test.ts b/packages/rrweb-snapshot/test/rebuild.test.ts index 097ff0989a..1140b0e1ec 100644 --- a/packages/rrweb-snapshot/test/rebuild.test.ts +++ b/packages/rrweb-snapshot/test/rebuild.test.ts @@ -84,21 +84,23 @@ describe('rebuild', function () { describe('add hover class to hover selector related rules', function () { it('will do nothing to css text without :hover', () => { - const cssText = 'body { color: white }'; + const cssText = 'body{color:white}'; expect(adaptCssForReplay(cssText, cache)).toEqual(cssText); }); it('can add hover class to css text', () => { const cssText = '.a:hover { color: white }'; expect(adaptCssForReplay(cssText, cache)).toEqual( - '.a:hover, .a.\\:hover { color: white }', + `.a:hover, +.a.\\:hover { color: white }`, ); }); it('can correctly add hover when in middle of selector', () => { const cssText = 'ul li a:hover img { color: white }'; expect(adaptCssForReplay(cssText, cache)).toEqual( - 'ul li a:hover img, ul li a.\\:hover img { color: white }', + `ul li a:hover img, +ul li a.\\:hover img { color: white }`, ); }); @@ -111,13 +113,14 @@ ul li.specified c:hover img { color: white }`; expect(adaptCssForReplay(cssText, cache)).toEqual( - `ul li.specified a:hover img, ul li.specified a.\\:hover img, + `ul li.specified a:hover img, ul li.multiline b:hover -img, ul li.multiline -b.\\:hover img, -ul li.specified c:hover img, ul li.specified c.\\:hover img { +ul li.specified c:hover img, +ul li.specified a.\\:hover img, +ul li.multiline b.\\:hover img, +ul li.specified c.\\:hover img { color: white }`, ); @@ -126,41 +129,55 @@ ul li.specified c:hover img, ul li.specified c.\\:hover img { it('can add hover class within media query', () => { const cssText = '@media screen { .m:hover { color: white } }'; expect(adaptCssForReplay(cssText, cache)).toEqual( - '@media screen { .m:hover, .m.\\:hover { color: white } }', + `@media screen { .m:hover, +.m.\\:hover { color: white } }`, ); }); it('can add hover class when there is multi selector', () => { const cssText = '.a, .b:hover, .c { color: white }'; expect(adaptCssForReplay(cssText, cache)).toEqual( - '.a, .b:hover, .b.\\:hover, .c { color: white }', + `.a, .b:hover, .c, +.b.\\:hover { color: white }`, ); }); it('can add hover class when there is a multi selector with the same prefix', () => { const cssText = '.a:hover, .a:hover::after { color: white }'; expect(adaptCssForReplay(cssText, cache)).toEqual( - '.a:hover, .a.\\:hover, .a:hover::after, .a.\\:hover::after { color: white }', + `.a:hover, .a:hover::after, +.a.\\:hover, +.a.\\:hover::after { color: white }`, ); }); it('can add hover class when :hover is not the end of selector', () => { const cssText = 'div:hover::after { color: white }'; expect(adaptCssForReplay(cssText, cache)).toEqual( - 'div:hover::after, div.\\:hover::after { color: white }', + `div:hover::after, +div.\\:hover::after { color: white }`, ); }); it('can add hover class when the selector has multi :hover', () => { const cssText = 'a:hover b:hover { color: white }'; expect(adaptCssForReplay(cssText, cache)).toEqual( - 'a:hover b:hover, a.\\:hover b.\\:hover { color: white }', + `a:hover b:hover, +a.\\:hover b.\\:hover { color: white }`, ); }); it('will ignore :hover in css value', () => { const cssText = '.a::after { content: ":hover" }'; - expect(adaptCssForReplay(cssText, cache)).toEqual(cssText); + expect(adaptCssForReplay(cssText, cache)).toEqual( + '.a::after { content: ":hover" }', + ); + }); + + it('should allow empty property value', () => { + expect(adaptCssForReplay('p { color:; }', cache)).toEqual( + 'p { color:; }', + ); }); it('can adapt media rules to replay context', () => { diff --git a/packages/rrweb-snapshot/test/utils.test.ts b/packages/rrweb-snapshot/test/utils.test.ts index afbdda2f42..ed4b3a0e61 100644 --- a/packages/rrweb-snapshot/test/utils.test.ts +++ b/packages/rrweb-snapshot/test/utils.test.ts @@ -2,7 +2,12 @@ * @jest-environment jsdom */ import { NodeType, serializedNode } from '../src/types'; -import { extractFileExtension, isNodeMetaEqual } from '../src/utils'; +import { + escapeImportStatement, + extractFileExtension, + fixSafariColons, + isNodeMetaEqual, +} from '../src/utils'; import { serializedNodeWithId } from 'rrweb-snapshot'; describe('utils', () => { @@ -198,4 +203,80 @@ describe('utils', () => { expect(extension).toBe('js'); }); }); + + describe('escapeImportStatement', () => { + it('parses imports with quotes correctly', () => { + const out1 = escapeImportStatement({ + cssText: `@import url("/foo.css;900;800"");`, + href: '/foo.css;900;800"', + media: { + length: 0, + }, + layerName: null, + supportsText: null, + } as unknown as CSSImportRule); + expect(out1).toEqual(`@import url("/foo.css;900;800\\"");`); + + const out2 = escapeImportStatement({ + cssText: `@import url("/foo.css;900;800"") supports(display: flex);`, + href: '/foo.css;900;800"', + media: { + length: 0, + }, + layerName: null, + supportsText: 'display: flex', + } as unknown as CSSImportRule); + expect(out2).toEqual( + `@import url("/foo.css;900;800\\"") supports(display: flex);`, + ); + + const out3 = escapeImportStatement({ + cssText: `@import url("/foo.css;900;800"");`, + href: '/foo.css;900;800"', + media: { + length: 1, + mediaText: 'print, screen', + }, + layerName: null, + supportsText: null, + } as unknown as CSSImportRule); + expect(out3).toEqual(`@import url("/foo.css;900;800\\"") print, screen;`); + + const out4 = escapeImportStatement({ + cssText: `@import url("/foo.css;900;800"") layer(layer-1);`, + href: '/foo.css;900;800"', + media: { + length: 0, + }, + layerName: 'layer-1', + supportsText: null, + } as unknown as CSSImportRule); + expect(out4).toEqual( + `@import url("/foo.css;900;800\\"") layer(layer-1);`, + ); + + const out5 = escapeImportStatement({ + cssText: `@import url("/foo.css;900;800"") layer;`, + href: '/foo.css;900;800"', + media: { + length: 0, + }, + layerName: '', + supportsText: null, + } as unknown as CSSImportRule); + expect(out5).toEqual(`@import url("/foo.css;900;800\\"") layer;`); + }); + }); + describe('fixSafariColons', () => { + it('parses : in attribute selectors correctly', () => { + const out1 = fixSafariColons('[data-foo] { color: red; }'); + expect(out1).toEqual('[data-foo] { color: red; }'); + + const out2 = fixSafariColons('[data-foo:other] { color: red; }'); + expect(out2).toEqual('[data-foo\\:other] { color: red; }'); + + const out3 = fixSafariColons('[data-aa\\:other] { color: red; }'); + expect(out3).toEqual('[data-aa\\:other] { color: red; }'); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 40f8fd25f3..9b812376d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3481,6 +3481,13 @@ dependencies: "@types/node" "*" +"@types/postcss-js@^4.0.4": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/postcss-js/-/postcss-js-4.0.4.tgz#4f0fafd7a0508c7edd33095f8743e8cf5c28ad78" + integrity sha512-j5+GMZVIPCJpRTwI/mO64mCzv7X+zAEq3JP0EV2lo/BrLWHAohEubUJimIAY23rH27+wKce0fXUYjAdBoqlaYw== + dependencies: + postcss "^8.3.3" + "@types/prettier@^2.1.5": version "2.4.1" resolved "https://registry.npmjs.org/@types/prettier/-/prettier-2.4.1.tgz" @@ -5564,10 +5571,15 @@ csso@^4.0.2: dependencies: css-tree "^1.1.2" -cssom@^0.4.4, cssom@^0.5.0, "cssom@https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz": +cssom@^0.4.4, "cssom@https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz": version "0.6.0" resolved "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1" +cssom@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.5.0.tgz#d254fa92cd8b6fbd83811b9fbaed34663cc17c36" + integrity sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw== + cssom@~0.3.6: version "0.3.8" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" @@ -11100,6 +11112,11 @@ nanoid@^3.3.1, nanoid@^3.3.4: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== +nanoid@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + nanoid@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.0.tgz#6e144dee117609232c3f415c34b0e550e64999a5" @@ -12255,6 +12272,15 @@ postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.27: picocolors "^0.2.1" source-map "^0.6.1" +postcss@^8.3.3, postcss@^8.4.38: + version "8.4.38" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e" + integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A== + dependencies: + nanoid "^3.3.7" + picocolors "^1.0.0" + source-map-js "^1.2.0" + postcss@^8.4.16, postcss@^8.4.18: version "8.4.18" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.18.tgz#6d50046ea7d3d66a85e0e782074e7203bc7fbca2" @@ -13567,6 +13593,11 @@ source-map-js@^1.0.2: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== +source-map-js@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" + integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== + source-map-support@0.5.13: version "0.5.13" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932"