Skip to content

Commit e78deae

Browse files
committed
add support for css nesting an generic at rules
1 parent 3389486 commit e78deae

11 files changed

Lines changed: 420 additions & 19 deletions

File tree

src/parse/index.ts

Lines changed: 183 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type CssDeclarationAST,
1111
type CssDocumentAST,
1212
type CssFontFaceAST,
13+
type CssGenericAtRuleAST,
1314
type CssHostAST,
1415
type CssImportAST,
1516
type CssKeyframeAST,
@@ -283,7 +284,8 @@ export const parse = (
283284
}
284285

285286
/**
286-
* Parse declarations.
287+
* Parse declarations (without nesting support).
288+
* Used by @font-face, @page, keyframes.
287289
*/
288290
function declarations() {
289291
const decls: Array<CssDeclarationAST | CssCommentAST> = [];
@@ -307,6 +309,126 @@ export const parse = (
307309
return decls;
308310
}
309311

312+
/**
313+
* Check if the current position looks like a nested rule
314+
* ('{' appears before ';' and '}' at the top level).
315+
*/
316+
function looksLikeNestedRule(): boolean {
317+
const bracePos = indexOfArrayWithBracketAndQuoteSupport(css, ['{']);
318+
if (bracePos === -1) {
319+
return false;
320+
}
321+
const semiPos = indexOfArrayWithBracketAndQuoteSupport(css, [';']);
322+
const closePos = indexOfArrayWithBracketAndQuoteSupport(css, ['}']);
323+
324+
if (semiPos !== -1 && semiPos < bracePos) {
325+
return false;
326+
}
327+
if (closePos !== -1 && closePos < bracePos) {
328+
return false;
329+
}
330+
return true;
331+
}
332+
333+
/**
334+
* Parse rule body with CSS nesting support.
335+
* Handles declarations, comments, nested rules, and nested at-rules.
336+
*/
337+
function ruleBody():
338+
| Array<CssDeclarationAST | CssCommentAST | CssAtRuleAST>
339+
| undefined {
340+
const items: Array<CssDeclarationAST | CssCommentAST | CssAtRuleAST> = [];
341+
342+
if (!open()) {
343+
return error("missing '{'");
344+
}
345+
comments(items);
346+
347+
while (css.length && css.charAt(0) !== '}') {
348+
// nested at-rule
349+
if (css.charAt(0) === '@') {
350+
const ar = atRule();
351+
if (ar) {
352+
items.push(ar);
353+
comments(items);
354+
continue;
355+
}
356+
}
357+
358+
// nested rule ('{' comes before ';' and '}')
359+
if (looksLikeNestedRule()) {
360+
const nestedR = rule();
361+
if (nestedR) {
362+
items.push(nestedR);
363+
comments(items);
364+
continue;
365+
}
366+
}
367+
368+
// declaration
369+
const decl = declaration();
370+
if (decl) {
371+
items.push(decl);
372+
comments(items);
373+
continue;
374+
}
375+
376+
// nothing matched
377+
break;
378+
}
379+
380+
if (!close()) {
381+
return error("missing '}'");
382+
}
383+
return items;
384+
}
385+
386+
/**
387+
* Parse rules, declarations, and nested rules.
388+
* Used by block at-rules (media, supports, etc.) to support
389+
* both top-level rules and declarations when nested inside a rule.
390+
*/
391+
function rulesOrDeclarations() {
392+
const items: Array<
393+
CssAtRuleAST | CssDeclarationAST | CssCommentAST
394+
> = [];
395+
whitespace();
396+
comments(items);
397+
while (css.length && css.charAt(0) !== '}') {
398+
// at-rule
399+
if (css.charAt(0) === '@') {
400+
const ar = atRule();
401+
if (ar) {
402+
items.push(ar);
403+
comments(items);
404+
continue;
405+
}
406+
}
407+
408+
// nested rule ('{' comes before ';' and '}')
409+
if (looksLikeNestedRule()) {
410+
const r = rule();
411+
if (r) {
412+
items.push(r);
413+
comments(items);
414+
continue;
415+
}
416+
}
417+
418+
// declaration
419+
const decl = declaration();
420+
if (decl) {
421+
items.push(decl);
422+
comments(items);
423+
continue;
424+
}
425+
426+
// nothing matched
427+
break;
428+
}
429+
return items;
430+
}
431+
310432
/**
311433
* Parse keyframe.
312434
*/
@@ -397,7 +519,7 @@ export const parse = (
397519
return error("@supports missing '{'");
398520
}
399521

400-
const style = comments<CssAtRuleAST>().concat(rules());
522+
const style = rulesOrDeclarations();
401523

402524
if (!close()) {
403525
return error("@supports missing '}'");
@@ -426,7 +548,7 @@ export const parse = (
426548
return error("@host missing '{'");
427549
}
428550

429-
const style = comments<CssAtRuleAST>().concat(rules());
551+
const style = rulesOrDeclarations();
430552

431553
if (!close()) {
432554
return error("@host missing '}'");
@@ -454,7 +576,7 @@ export const parse = (
454576
return error("@container missing '{'");
455577
}
456578

457-
const style = comments<CssAtRuleAST>().concat(rules());
579+
const style = rulesOrDeclarations();
458580

459581
if (!close()) {
460582
return error("@container missing '}'");
@@ -490,7 +612,7 @@ export const parse = (
490612
});
491613
}
492614

493-
const style = comments<CssAtRuleAST>().concat(rules());
615+
const style = rulesOrDeclarations();
494616

495617
if (!close()) {
496618
return error("@layer missing '}'");
@@ -519,7 +641,7 @@ export const parse = (
519641
return error("@media missing '{'");
520642
}
521643

522-
const style = comments<CssAtRuleAST>().concat(rules());
644+
const style = rulesOrDeclarations();
523645

524646
if (!close()) {
525647
return error("@media missing '}'");
@@ -605,7 +727,7 @@ export const parse = (
605727
return error("@document missing '{'");
606728
}
607729

608-
const style = comments<CssAtRuleAST>().concat(rules());
730+
const style = rulesOrDeclarations();
609731

610732
if (!close()) {
611733
return error("@document missing '}'");
@@ -667,7 +789,7 @@ export const parse = (
667789
if (!open()) {
668790
return error("@starting-style missing '{'");
669791
}
670-
const style = comments<CssAtRuleAST>().concat(rules());
792+
const style = rulesOrDeclarations();
671793

672794
if (!close()) {
673795
return error("@starting-style missing '}'");
@@ -721,6 +843,56 @@ export const parse = (
721843
};
722844
}
723845

846+
/**
847+
* Parse generic/unknown at-rule (fallback for any unrecognized at-rule).
848+
* Handles both block at-rules (@scope { ... }) and statement at-rules (@foo ...;).
849+
*/
850+
function atGeneric(): CssGenericAtRuleAST | undefined {
851+
const pos = position();
852+
const m = /^@([-\w]+)\s*/.exec(css);
853+
if (!m) {
854+
return;
855+
}
856+
const name = processMatch(m)[1];
857+
858+
// Capture prelude (everything between the name and '{' or ';')
859+
let prelude = '';
860+
const preludeEnd = indexOfArrayWithBracketAndQuoteSupport(css, ['{', ';']);
861+
if (preludeEnd !== -1 && preludeEnd > 0) {
862+
prelude = trim(css.substring(0, preludeEnd));
863+
const fakeMatch = [css.substring(0, preludeEnd)] as unknown as RegExpExecArray;
864+
processMatch(fakeMatch);
865+
}
866+
867+
// Block at-rule
868+
if (open()) {
869+
const style = rulesOrDeclarations();
870+
871+
if (!close()) {
872+
return error(`@${name} missing '}'`);
873+
}
874+
875+
return pos<CssGenericAtRuleAST>({
876+
type: CssTypes.atRule,
877+
name: name,
878+
prelude: prelude,
879+
rules: style,
880+
});
881+
}
882+
883+
// Statement at-rule (ends with ';')
884+
const endMatch = /^[;\s]*/.exec(css);
885+
if (endMatch) {
886+
processMatch(endMatch);
887+
}
888+
889+
return pos<CssGenericAtRuleAST>({
890+
type: CssTypes.atRule,
891+
name: name,
892+
prelude: prelude,
893+
});
894+
}
895+
724896
/**
725897
* Parse at rule.
726898
*/
@@ -743,7 +915,8 @@ export const parse = (
743915
atFontFace() ||
744916
atContainer() ||
745917
atStartingStyle() ||
746-
atLayer()
918+
atLayer() ||
919+
atGeneric()
747920
);
748921
}
749922

@@ -762,7 +935,7 @@ export const parse = (
762935
return pos<CssRuleAST>({
763936
type: CssTypes.rule,
764937
selectors: sel,
765-
declarations: declarations() || [],
938+
declarations: ruleBody() || [],
766939
});
767940
}
768941

src/stringify/compiler.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
type CssDeclarationAST,
99
type CssDocumentAST,
1010
type CssFontFaceAST,
11+
type CssGenericAtRuleAST,
1112
type CssHostAST,
1213
type CssImportAST,
1314
type CssKeyframeAST,
@@ -102,6 +103,8 @@ class Compiler {
102103
return this.startingStyle(node);
103104
case CssTypes.supports:
104105
return this.supports(node);
106+
case CssTypes.atRule:
107+
return this.genericAtRule(node);
105108
}
106109
}
107110

@@ -414,6 +417,43 @@ class Compiler {
414417
);
415418
}
416419

420+
/**
421+
* Visit generic at-rule node (fallback for any unrecognized at-rule).
422+
*/
423+
genericAtRule(node: CssGenericAtRuleAST) {
424+
const prelude = node.prelude ? ` ${node.prelude}` : '';
425+
if (this.compress) {
426+
return (
427+
this.emit(`@${node.name}${prelude}`, node.position) +
428+
(node.rules
429+
? this.emit('{') +
430+
this.mapVisit(<CssAllNodesAST[]>node.rules) +
431+
this.emit('}')
432+
: ';')
433+
);
434+
}
435+
if (!node.rules) {
436+
return this.emit(
437+
`${this.indent()}@${node.name}${prelude};`,
438+
node.position,
439+
);
440+
}
441+
const hasNestedRules = node.rules.some(
442+
(r) =>
443+
r.type !== CssTypes.declaration && r.type !== CssTypes.comment,
444+
);
445+
const delim = hasNestedRules ? '\n\n' : '\n';
446+
return (
447+
this.emit(`${this.indent()}@${node.name}${prelude}`, node.position) +
448+
this.emit(hasNestedRules ? ` {\n${this.indent(1)}` : ' {\n') +
449+
this.emit(hasNestedRules ? '' : this.indent(1)) +
450+
this.mapVisit(<CssAllNodesAST[]>node.rules, delim) +
451+
this.emit(hasNestedRules
452+
? `\n${this.indent(-1)}${this.indent()}}`
453+
: `${this.indent(-1)}\n${this.indent()}}`)
454+
);
455+
}
456+
417457
/**
418458
* Visit rule node.
419459
*/

0 commit comments

Comments
 (0)