@@ -38,6 +38,102 @@ function getIgnoreComment(node) {
3838 }
3939}
4040
41+ // Parse @scope at-rule params into scope-start / scope-end clauses.
42+ // Grammar: <scope-start>? (to <scope-end>)? where each clause is "(...)".
43+ // Tracks paren depth, string literals, CSS comments, and identifier escapes
44+ // so the "to" keyword is detected at the structural position rather than
45+ // matched as a substring. Returns null on unparseable input. Fixes #90.
46+ function parseScopeParams ( params ) {
47+ const len = params . length ;
48+ let i = 0 ;
49+
50+ const skipWs = ( ) => {
51+ while ( i < len ) {
52+ if ( / \s / . test ( params [ i ] ) ) {
53+ i ++ ;
54+ } else if ( params [ i ] === "/" && params [ i + 1 ] === "*" ) {
55+ const close = params . indexOf ( "*/" , i + 2 ) ;
56+ if ( close === - 1 ) return false ;
57+ i = close + 2 ;
58+ } else {
59+ return true ;
60+ }
61+ }
62+ return true ;
63+ } ;
64+
65+ // Read inner contents of `(...)` at position i; advance i past `)`.
66+ // Tracks paren depth, string literals (with backslash escapes), CSS
67+ // comments, and CSS ident escapes (`\(`, `\)`, etc.).
68+ const readParens = ( ) => {
69+ if ( params [ i ] !== "(" ) return null ;
70+ let depth = 1 ;
71+ let j = i + 1 ;
72+ let inStr = null ;
73+ while ( j < len && depth > 0 ) {
74+ const c = params [ j ] ;
75+ if ( inStr ) {
76+ if ( c === "\\" ) {
77+ j += 2 ; // skip the escaped char (incl. closing quote, newline, etc.)
78+ continue ;
79+ }
80+ if ( c === inStr ) inStr = null ;
81+ j ++ ;
82+ } else if ( c === "\\" ) {
83+ j += 2 ; // CSS ident escape — the next char is literal, ignore parens
84+ } else if ( c === "/" && params [ j + 1 ] === "*" ) {
85+ const close = params . indexOf ( "*/" , j + 2 ) ;
86+ if ( close === - 1 ) return null ;
87+ j = close + 2 ;
88+ } else if ( c === '"' || c === "'" ) {
89+ inStr = c ;
90+ j ++ ;
91+ } else if ( c === "(" ) {
92+ depth ++ ;
93+ j ++ ;
94+ } else if ( c === ")" ) {
95+ depth -- ;
96+ j ++ ;
97+ } else {
98+ j ++ ;
99+ }
100+ }
101+ if ( depth !== 0 ) return null ;
102+ const inner = params . slice ( i + 1 , j - 1 ) ;
103+ i = j ;
104+ return inner ;
105+ } ;
106+
107+ if ( ! skipWs ( ) ) return null ;
108+ let start = null ;
109+ let end = null ;
110+
111+ if ( params [ i ] === "(" ) {
112+ start = readParens ( ) ;
113+ if ( start === null ) return null ;
114+ if ( ! skipWs ( ) ) return null ;
115+ }
116+
117+ if ( i < len ) {
118+ // Expect "to" keyword (case-insensitive per CSS) followed by `(scope-end)`.
119+ if ( params . slice ( i , i + 2 ) . toLowerCase ( ) !== "to" ) return null ;
120+ // Boundary check: the char after "to" must be whitespace, "(", or
121+ // start-of-comment. Empty fallback is safe: if "to" is at end-of-input
122+ // with no trailing context, the subsequent paren check rejects it.
123+ const next = i + 2 < len ? params [ i + 2 ] : "" ;
124+ if ( next !== "" && ! / \s | \( / . test ( next ) && next !== "/" ) return null ;
125+ i += 2 ;
126+ if ( ! skipWs ( ) ) return null ;
127+ if ( params [ i ] !== "(" ) return null ;
128+ end = readParens ( ) ;
129+ if ( end === null ) return null ;
130+ if ( ! skipWs ( ) ) return null ;
131+ if ( i < len ) return null ; // trailing garbage
132+ }
133+
134+ return { start, end } ;
135+ }
136+
41137function normalizeNodeArray ( nodes ) {
42138 const array = [ ] ;
43139
@@ -561,7 +657,7 @@ module.exports = (options = {}) => {
561657 const localAliasMap = new Map ( ) ;
562658
563659 return {
564- Once ( root ) {
660+ Once ( root , { result } ) {
565661 const { icssImports } = extractICSS ( root , false ) ;
566662 const enforcePureMode = pureMode && ! isPureCheckDisabled ( root ) ;
567663
@@ -623,19 +719,24 @@ module.exports = (options = {}) => {
623719 ignoreComment . remove ( ) ;
624720 }
625721
626- atRule . params = atRule . params
627- . split ( "to" )
628- . map ( ( item ) => {
629- const selector = item . trim ( ) . slice ( 1 , - 1 ) . trim ( ) ;
722+ const parsed = parseScopeParams ( atRule . params ) ;
723+ if ( ! parsed ) {
724+ atRule . warn (
725+ result ,
726+ "Could not parse @scope params; selectors will not be " +
727+ "localized for this rule. Params: " +
728+ JSON . stringify ( atRule . params )
729+ ) ;
730+ }
731+ if ( parsed ) {
732+ const localizeSelector = ( selector ) => {
630733 const context = localizeNode (
631734 selector ,
632735 options . mode ,
633736 localAliasMap
634737 ) ;
635-
636738 context . options = options ;
637739 context . localAliasMap = localAliasMap ;
638-
639740 if (
640741 enforcePureMode &&
641742 context . hasPureGlobals &&
@@ -648,10 +749,26 @@ module.exports = (options = {}) => {
648749 "(pure selectors must contain at least one local class or id)"
649750 ) ;
650751 }
651-
652- return `(${ context . selector } )` ;
653- } )
654- . join ( " to " ) ;
752+ return context . selector ;
753+ } ;
754+
755+ const start =
756+ parsed . start !== null
757+ ? localizeSelector ( parsed . start . trim ( ) )
758+ : null ;
759+ const end =
760+ parsed . end !== null
761+ ? localizeSelector ( parsed . end . trim ( ) )
762+ : null ;
763+
764+ if ( start !== null && end !== null ) {
765+ atRule . params = `(${ start } ) to (${ end } )` ;
766+ } else if ( start !== null ) {
767+ atRule . params = `(${ start } )` ;
768+ } else if ( end !== null ) {
769+ atRule . params = `to (${ end } )` ;
770+ }
771+ }
655772 }
656773
657774 atRule . nodes . forEach ( ( declaration ) => {
0 commit comments