Files
rrweb/packages/rrweb-snapshot/src/css.ts
Eoghan Murray 91bdd6e2c8 No neg lookbehind (#1493)
* Older versions of Safari 16 don't support lookbehind assertions - https://caniuse.com/js-regexp-lookbehind

* Refactor to show the similarity between these two regexes

* Apply formatting changes

* Create no-neg-lookbehind.md

---------

Co-authored-by: eoghanmurray <eoghanmurray@users.noreply.github.com>
2026-04-01 12:00:00 +08:00

986 lines
20 KiB
TypeScript

/**
* 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 */
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<Rule | Comment | AtRule>;
}
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<Declaration | Comment>;
}
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<Declaration | Comment>;
}
/**
* 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<KeyFrame | Comment>;
}
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<Declaration | Comment>;
}
/**
* 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<Declaration | Comment>;
}
/**
* 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();
return {
type: 'stylesheet',
stylesheet: {
source: options.source,
rules: rulesList,
parsingErrors: errorsList,
},
};
}
/**
* 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.
*/
// originally from https://github.com/NxtChg/pieces/blob/3eb39c8287a97632e9347a24f333d52d916bc816/js/css_parser/css_parse.js#L46C1-L47C1
const selectorMatcher = new RegExp(
'^((' +
[
/[^\\]"(?:\\"|[^"])*"/.source, // consume any quoted parts (checking that the double quote isn't itself escaped)
/[^\\]'(?:\\'|[^'])*'/.source, // same but for single quotes
'[^{]',
].join('|') +
')+)',
);
function selector() {
whitespace();
while (css[0] == '}') {
error('extra closing bracket');
css = css.slice(1);
whitespace();
}
const m = match(selectorMatcher);
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;
}
} 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 ':'`);
}
// val
// eslint-disable-next-line no-useless-escape
const val = match(/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^\)]*?\)|[^};])+)/);
const ret = pos({
type: 'declaration',
property: prop.replace(commentre, ''),
value: val ? trim(val[0]).replace(commentre, '') : '',
});
// ;
match(/^[;\s]*/);
return ret;
}
/**
* Parse declarations.
*/
function declarations() {
const decls: Array<object> = [];
if (!open()) {
return error(`missing '{'`);
}
comments(decls);
// declarations
let decl;
while ((decl = declaration())) {
if ((decl as unknown) !== false) {
decls.push(decl);
comments(decls);
}
decl = declaration();
}
if (!close()) {
return error(`missing '}'`);
}
return decls;
}
/**
* Parse keyframe.
*/
function keyframe() {
let m;
const vals = [];
const pos = position();
while ((m = match(/^((\d+\.\d+|\.\d+|\d+)%?|[a-z]+)\s*/))) {
vals.push(m[1]);
match(/^,\s*/);
}
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*((?:' +
[
/[^\\]"(?:\\"|[^"])*"/.source, // consume any quoted parts (checking that the double quote isn't itself escaped)
/[^\\]'(?:\\'|[^'])*'/.source, // same but for single quotes
'[^;]',
].join('|') +
')+);',
);
return () => {
const pos = position();
const m = match(re);
if (!m) {
return;
}
const ret: Record<string, string> = { type: name };
ret[name] = m[1].trim();
return pos(ret);
};
}
/**
* 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;
}