diff --git a/src/parse/index.ts b/src/parse/index.ts index 32a3a1ad..f6b4633b 100644 --- a/src/parse/index.ts +++ b/src/parse/index.ts @@ -34,6 +34,7 @@ import { } from '../type'; import { indexOfArrayWithBracketAndQuoteSupport, + removeCommentWithQuoteSupport, splitWithBracketAndQuoteSupport, } from '../utils/stringSearch'; @@ -256,8 +257,8 @@ export const parse = ( const fakeMatch = [selectorStr] as unknown as RegExpExecArray; processMatch(fakeMatch); - // remove comment in selector; - const res = trim(selectorStr).replace(commentRegex, ''); + // remove comment in selector, but keep comment-like text inside strings; + const res = removeCommentWithQuoteSupport(trim(selectorStr)); return splitWithBracketAndQuoteSupport(res, [',']).map((v) => trim(v)); } diff --git a/src/utils/stringSearch.ts b/src/utils/stringSearch.ts index 3d99c3ba..a891740e 100644 --- a/src/utils/stringSearch.ts +++ b/src/utils/stringSearch.ts @@ -172,3 +172,63 @@ export const splitWithBracketAndQuoteSupport = ( } return result; }; + +/** + * Remove `/* ... *\/` comments from a string while preserving the content of + * quoted strings. A `/*` inside a quoted string does not start a comment, so + * comment-like text in an attribute-selector value must be kept verbatim. + * @example + * ```ts + * removeCommentWithQuoteSupport('a /*c*\/ b') // 'a b' + * removeCommentWithQuoteSupport('a[title="/*x*\/"]') // 'a[title="/*x*\/"]' + * ``` + */ +export const removeCommentWithQuoteSupport = (string: string): string => { + let result = ''; + let currentPosition = 0; + let maxLoop = MAX_LOOP; + + while (currentPosition < string.length && maxLoop > 0) { + const all = [ + string.indexOf('/*', currentPosition), + string.indexOf('"', currentPosition), + string.indexOf("'", currentPosition), + ].filter((v) => v !== -1); + + if (all.length === 0) { + result += string.substring(currentPosition); + return result; + } + + const firstMatchPos = Math.min(...all); + result += string.substring(currentPosition, firstMatchPos); + const char = string[firstMatchPos]; + + if (char === '/') { + const endComment = string.indexOf('*/', firstMatchPos + 2); + if (endComment === -1) { + return result; + } + currentPosition = endComment + 2; + } else { + const endQuotePosition = indexOfArrayNonEscaped( + string, + [char], + firstMatchPos + 1, + ); + if (endQuotePosition === -1) { + result += string.substring(firstMatchPos); + return result; + } + result += string.substring(firstMatchPos, endQuotePosition + 1); + currentPosition = endQuotePosition + 1; + } + maxLoop--; + } + + if (maxLoop <= 0) { + throw new Error('Too many escaping'); + } + + return result; +}; diff --git a/test/parse.test.ts b/test/parse.test.ts index 78e5be80..7ebc5b51 100644 --- a/test/parse.test.ts +++ b/test/parse.test.ts @@ -360,4 +360,54 @@ describe('parse(str)', () => { }).toThrow(); }); }); + + describe('comment-like text inside attribute-selector strings', () => { + it('should keep a comment-like attribute value intact', () => { + const css = 'a[title="/*x*/"] { color: red; }'; + const ast = parse(css); + const rule = ast.stylesheet.rules[0] as CssRuleAST; + + expect(rule.selectors).toEqual(['a[title="/*x*/"]']); + }); + + it('should keep comment-like text spliced inside an attribute value', () => { + const css = 'a[data="a/*b*/c"] { color: red; }'; + const ast = parse(css); + const rule = ast.stylesheet.rules[0] as CssRuleAST; + + expect(rule.selectors).toEqual(['a[data="a/*b*/c"]']); + }); + + it('should keep comment-like text inside single-quoted attribute values', () => { + const css = "a[data='/*x*/'] { color: red; }"; + const ast = parse(css); + const rule = ast.stylesheet.rules[0] as CssRuleAST; + + expect(rule.selectors).toEqual(["a[data='/*x*/']"]); + }); + + it('should still strip a real comment that precedes a quoted attribute value', () => { + const css = 'a:not(/*x*/.b) { color: red; }'; + const ast = parse(css); + const rule = ast.stylesheet.rules[0] as CssRuleAST; + + expect(rule.selectors).toEqual(['a:not(.b)']); + }); + + it('should still strip a real comment between two compound selectors', () => { + const css = 'a /*c*/ b { color: red; }'; + const ast = parse(css); + const rule = ast.stylesheet.rules[0] as CssRuleAST; + + expect(rule.selectors).toEqual(['a b']); + }); + + it('should strip a real comment but keep an adjacent comment-like string', () => { + const css = 'a[title="/*x*/"]/*real*/ { color: red; }'; + const ast = parse(css); + const rule = ast.stylesheet.rules[0] as CssRuleAST; + + expect(rule.selectors).toEqual(['a[title="/*x*/"]']); + }); + }); });