From e78deaea31db11b940b5372d70c8476c3c0b1a21 Mon Sep 17 00:00:00 2001 From: jkuehner Date: Tue, 17 Mar 2026 11:18:41 +0100 Subject: [PATCH 1/6] add support for css nesting an generic at rules --- src/parse/index.ts | 193 +++++++++++++++++++-- src/stringify/compiler.ts | 40 +++++ src/type.ts | 27 ++- test/cases/css-nesting/ast.json | 1 + test/cases/css-nesting/compressed.css | 1 + test/cases/css-nesting/input.css | 57 ++++++ test/cases/css-nesting/output.css | 46 +++++ test/cases/generic-at-rules/ast.json | 1 + test/cases/generic-at-rules/compressed.css | 1 + test/cases/generic-at-rules/input.css | 36 ++++ test/cases/generic-at-rules/output.css | 36 ++++ 11 files changed, 420 insertions(+), 19 deletions(-) create mode 100644 test/cases/css-nesting/ast.json create mode 100644 test/cases/css-nesting/compressed.css create mode 100644 test/cases/css-nesting/input.css create mode 100644 test/cases/css-nesting/output.css create mode 100644 test/cases/generic-at-rules/ast.json create mode 100644 test/cases/generic-at-rules/compressed.css create mode 100644 test/cases/generic-at-rules/input.css create mode 100644 test/cases/generic-at-rules/output.css diff --git a/src/parse/index.ts b/src/parse/index.ts index 11a37846..0214553a 100644 --- a/src/parse/index.ts +++ b/src/parse/index.ts @@ -10,6 +10,7 @@ import { type CssDeclarationAST, type CssDocumentAST, type CssFontFaceAST, + type CssGenericAtRuleAST, type CssHostAST, type CssImportAST, type CssKeyframeAST, @@ -283,7 +284,8 @@ export const parse = ( } /** - * Parse declarations. + * Parse declarations (without nesting support). + * Used by @font-face, @page, keyframes. */ function declarations() { const decls: Array = []; @@ -307,6 +309,126 @@ export const parse = ( return decls; } + /** + * Check if the current position looks like a nested rule + * ('{' appears before ';' and '}' at the top level). + */ + function looksLikeNestedRule(): boolean { + const bracePos = indexOfArrayWithBracketAndQuoteSupport(css, ['{']); + if (bracePos === -1) { + return false; + } + const semiPos = indexOfArrayWithBracketAndQuoteSupport(css, [';']); + const closePos = indexOfArrayWithBracketAndQuoteSupport(css, ['}']); + + if (semiPos !== -1 && semiPos < bracePos) { + return false; + } + if (closePos !== -1 && closePos < bracePos) { + return false; + } + return true; + } + + /** + * Parse rule body with CSS nesting support. + * Handles declarations, comments, nested rules, and nested at-rules. + */ + function ruleBody(): + | Array + | undefined { + const items: Array = []; + + if (!open()) { + return error("missing '{'"); + } + comments(items); + + while (css.length && css.charAt(0) !== '}') { + // nested at-rule + if (css.charAt(0) === '@') { + const ar = atRule(); + if (ar) { + items.push(ar); + comments(items); + continue; + } + } + + // nested rule ('{' comes before ';' and '}') + if (looksLikeNestedRule()) { + const nestedR = rule(); + if (nestedR) { + items.push(nestedR); + comments(items); + continue; + } + } + + // declaration + const decl = declaration(); + if (decl) { + items.push(decl); + comments(items); + continue; + } + + // nothing matched + break; + } + + if (!close()) { + return error("missing '}'"); + } + return items; + } + + /** + * Parse rules, declarations, and nested rules. + * Used by block at-rules (media, supports, etc.) to support + * both top-level rules and declarations when nested inside a rule. + */ + function rulesOrDeclarations() { + const items: Array< + CssAtRuleAST | CssDeclarationAST | CssCommentAST + > = []; + whitespace(); + comments(items); + while (css.length && css.charAt(0) !== '}') { + // at-rule + if (css.charAt(0) === '@') { + const ar = atRule(); + if (ar) { + items.push(ar); + comments(items); + continue; + } + } + + // nested rule ('{' comes before ';' and '}') + if (looksLikeNestedRule()) { + const r = rule(); + if (r) { + items.push(r); + comments(items); + continue; + } + } + + // declaration + const decl = declaration(); + if (decl) { + items.push(decl); + comments(items); + continue; + } + + // nothing matched + break; + } + return items; + } + /** * Parse keyframe. */ @@ -397,7 +519,7 @@ export const parse = ( return error("@supports missing '{'"); } - const style = comments().concat(rules()); + const style = rulesOrDeclarations(); if (!close()) { return error("@supports missing '}'"); @@ -426,7 +548,7 @@ export const parse = ( return error("@host missing '{'"); } - const style = comments().concat(rules()); + const style = rulesOrDeclarations(); if (!close()) { return error("@host missing '}'"); @@ -454,7 +576,7 @@ export const parse = ( return error("@container missing '{'"); } - const style = comments().concat(rules()); + const style = rulesOrDeclarations(); if (!close()) { return error("@container missing '}'"); @@ -490,7 +612,7 @@ export const parse = ( }); } - const style = comments().concat(rules()); + const style = rulesOrDeclarations(); if (!close()) { return error("@layer missing '}'"); @@ -519,7 +641,7 @@ export const parse = ( return error("@media missing '{'"); } - const style = comments().concat(rules()); + const style = rulesOrDeclarations(); if (!close()) { return error("@media missing '}'"); @@ -605,7 +727,7 @@ export const parse = ( return error("@document missing '{'"); } - const style = comments().concat(rules()); + const style = rulesOrDeclarations(); if (!close()) { return error("@document missing '}'"); @@ -667,7 +789,7 @@ export const parse = ( if (!open()) { return error("@starting-style missing '{'"); } - const style = comments().concat(rules()); + const style = rulesOrDeclarations(); if (!close()) { return error("@starting-style missing '}'"); @@ -721,6 +843,56 @@ export const parse = ( }; } + /** + * Parse generic/unknown at-rule (fallback for any unrecognized at-rule). + * Handles both block at-rules (@scope { ... }) and statement at-rules (@foo ...;). + */ + function atGeneric(): CssGenericAtRuleAST | undefined { + const pos = position(); + const m = /^@([-\w]+)\s*/.exec(css); + if (!m) { + return; + } + const name = processMatch(m)[1]; + + // Capture prelude (everything between the name and '{' or ';') + let prelude = ''; + const preludeEnd = indexOfArrayWithBracketAndQuoteSupport(css, ['{', ';']); + if (preludeEnd !== -1 && preludeEnd > 0) { + prelude = trim(css.substring(0, preludeEnd)); + const fakeMatch = [css.substring(0, preludeEnd)] as unknown as RegExpExecArray; + processMatch(fakeMatch); + } + + // Block at-rule + if (open()) { + const style = rulesOrDeclarations(); + + if (!close()) { + return error(`@${name} missing '}'`); + } + + return pos({ + type: CssTypes.atRule, + name: name, + prelude: prelude, + rules: style, + }); + } + + // Statement at-rule (ends with ';') + const endMatch = /^[;\s]*/.exec(css); + if (endMatch) { + processMatch(endMatch); + } + + return pos({ + type: CssTypes.atRule, + name: name, + prelude: prelude, + }); + } + /** * Parse at rule. */ @@ -743,7 +915,8 @@ export const parse = ( atFontFace() || atContainer() || atStartingStyle() || - atLayer() + atLayer() || + atGeneric() ); } @@ -762,7 +935,7 @@ export const parse = ( return pos({ type: CssTypes.rule, selectors: sel, - declarations: declarations() || [], + declarations: ruleBody() || [], }); } diff --git a/src/stringify/compiler.ts b/src/stringify/compiler.ts index 89355fe6..5aad00c9 100644 --- a/src/stringify/compiler.ts +++ b/src/stringify/compiler.ts @@ -8,6 +8,7 @@ import { type CssDeclarationAST, type CssDocumentAST, type CssFontFaceAST, + type CssGenericAtRuleAST, type CssHostAST, type CssImportAST, type CssKeyframeAST, @@ -102,6 +103,8 @@ class Compiler { return this.startingStyle(node); case CssTypes.supports: return this.supports(node); + case CssTypes.atRule: + return this.genericAtRule(node); } } @@ -414,6 +417,43 @@ class Compiler { ); } + /** + * Visit generic at-rule node (fallback for any unrecognized at-rule). + */ + genericAtRule(node: CssGenericAtRuleAST) { + const prelude = node.prelude ? ` ${node.prelude}` : ''; + if (this.compress) { + return ( + this.emit(`@${node.name}${prelude}`, node.position) + + (node.rules + ? this.emit('{') + + this.mapVisit(node.rules) + + this.emit('}') + : ';') + ); + } + if (!node.rules) { + return this.emit( + `${this.indent()}@${node.name}${prelude};`, + node.position, + ); + } + const hasNestedRules = node.rules.some( + (r) => + r.type !== CssTypes.declaration && r.type !== CssTypes.comment, + ); + const delim = hasNestedRules ? '\n\n' : '\n'; + return ( + this.emit(`${this.indent()}@${node.name}${prelude}`, node.position) + + this.emit(hasNestedRules ? ` {\n${this.indent(1)}` : ' {\n') + + this.emit(hasNestedRules ? '' : this.indent(1)) + + this.mapVisit(node.rules, delim) + + this.emit(hasNestedRules + ? `\n${this.indent(-1)}${this.indent()}}` + : `${this.indent(-1)}\n${this.indent()}}`) + ); + } + /** * Visit rule node. */ diff --git a/src/type.ts b/src/type.ts index e2d34a37..3f015ff9 100644 --- a/src/type.ts +++ b/src/type.ts @@ -6,6 +6,7 @@ export enum CssTypes { rule = 'rule', declaration = 'declaration', comment = 'comment', + atRule = 'at-rule', container = 'container', charset = 'charset', document = 'document', @@ -44,7 +45,7 @@ export type CssStylesheetAST = CssCommonAST & { export type CssRuleAST = CssCommonPositionAST & { type: CssTypes.rule; selectors: Array; - declarations: Array; + declarations: Array; }; export type CssDeclarationAST = CssCommonPositionAST & { @@ -60,7 +61,7 @@ export type CssCommentAST = CssCommonPositionAST & { export type CssContainerAST = CssCommonPositionAST & { type: CssTypes.container; container: string; - rules: Array; + rules: Array; }; export type CssCharsetAST = CssCommonPositionAST & { @@ -76,7 +77,7 @@ export type CssDocumentAST = CssCommonPositionAST & { type: CssTypes.document; document: string; vendor?: string; - rules: Array; + rules: Array; }; export type CssFontFaceAST = CssCommonPositionAST & { type: CssTypes.fontFace; @@ -84,7 +85,7 @@ export type CssFontFaceAST = CssCommonPositionAST & { }; export type CssHostAST = CssCommonPositionAST & { type: CssTypes.host; - rules: Array; + rules: Array; }; export type CssImportAST = CssCommonPositionAST & { type: CssTypes.import; @@ -104,12 +105,12 @@ export type CssKeyframeAST = CssCommonPositionAST & { export type CssLayerAST = CssCommonPositionAST & { type: CssTypes.layer; layer: string; - rules?: Array; + rules?: Array; }; export type CssMediaAST = CssCommonPositionAST & { type: CssTypes.media; media: string; - rules: Array; + rules: Array; }; export type CssNamespaceAST = CssCommonPositionAST & { type: CssTypes.namespace; @@ -123,12 +124,19 @@ export type CssPageAST = CssCommonPositionAST & { export type CssSupportsAST = CssCommonPositionAST & { type: CssTypes.supports; supports: string; - rules: Array; + rules: Array; }; export type CssStartingStyleAST = CssCommonPositionAST & { type: CssTypes.startingStyle; - rules: Array; + rules: Array; +}; + +export type CssGenericAtRuleAST = CssCommonPositionAST & { + type: CssTypes.atRule; + name: string; + prelude: string; + rules?: Array; }; export type CssAtRuleAST = @@ -147,7 +155,8 @@ export type CssAtRuleAST = | CssNamespaceAST | CssPageAST | CssSupportsAST - | CssStartingStyleAST; + | CssStartingStyleAST + | CssGenericAtRuleAST; export type CssAllNodesAST = | CssAtRuleAST diff --git a/test/cases/css-nesting/ast.json b/test/cases/css-nesting/ast.json new file mode 100644 index 00000000..c2e29a2a --- /dev/null +++ b/test/cases/css-nesting/ast.json @@ -0,0 +1 @@ +{"type":"stylesheet","stylesheet":{"source":"input.css","rules":[{"type":"rule","selectors":[".parent"],"declarations":[{"type":"declaration","property":"color","value":"red","position":{"start":{"line":2,"column":3},"end":{"line":2,"column":13},"source":"input.css"}},{"type":"rule","selectors":[".child"],"declarations":[{"type":"declaration","property":"color","value":"blue","position":{"start":{"line":5,"column":5},"end":{"line":5,"column":16},"source":"input.css"}}],"position":{"start":{"line":4,"column":3},"end":{"line":6,"column":4},"source":"input.css"}},{"type":"rule","selectors":["&:hover"],"declarations":[{"type":"declaration","property":"color","value":"green","position":{"start":{"line":9,"column":5},"end":{"line":9,"column":17},"source":"input.css"}}],"position":{"start":{"line":8,"column":3},"end":{"line":10,"column":4},"source":"input.css"}},{"type":"rule","selectors":["& .descendant"],"declarations":[{"type":"declaration","property":"font-size","value":"14px","position":{"start":{"line":13,"column":5},"end":{"line":13,"column":20},"source":"input.css"}}],"position":{"start":{"line":12,"column":3},"end":{"line":14,"column":4},"source":"input.css"}},{"type":"rule","selectors":["> .direct"],"declarations":[{"type":"declaration","property":"margin","value":"0","position":{"start":{"line":17,"column":5},"end":{"line":17,"column":14},"source":"input.css"}}],"position":{"start":{"line":16,"column":3},"end":{"line":18,"column":4},"source":"input.css"}},{"type":"rule","selectors":["+ .sibling"],"declarations":[{"type":"declaration","property":"padding","value":"10px","position":{"start":{"line":21,"column":5},"end":{"line":21,"column":18},"source":"input.css"}}],"position":{"start":{"line":20,"column":3},"end":{"line":22,"column":4},"source":"input.css"}},{"type":"rule","selectors":[".deeply-nested"],"declarations":[{"type":"declaration","property":"font-weight","value":"bold","position":{"start":{"line":25,"column":5},"end":{"line":25,"column":22},"source":"input.css"}},{"type":"rule","selectors":[".even-deeper"],"declarations":[{"type":"declaration","property":"font-style","value":"italic","position":{"start":{"line":28,"column":7},"end":{"line":28,"column":25},"source":"input.css"}}],"position":{"start":{"line":27,"column":5},"end":{"line":29,"column":6},"source":"input.css"}}],"position":{"start":{"line":24,"column":3},"end":{"line":30,"column":4},"source":"input.css"}},{"type":"media","media":"(min-width: 768px)","rules":[{"type":"declaration","property":"font-size","value":"18px","position":{"start":{"line":33,"column":5},"end":{"line":33,"column":20},"source":"input.css"}}],"position":{"start":{"line":32,"column":3},"end":{"line":34,"column":4},"source":"input.css"}}],"position":{"start":{"line":1,"column":1},"end":{"line":35,"column":2},"source":"input.css"}},{"type":"rule","selectors":[".special-chars"],"declarations":[{"type":"declaration","property":"content","value":"\"semicolon ; and brace }\"","position":{"start":{"line":38,"column":3},"end":{"line":38,"column":37},"source":"input.css"}},{"type":"declaration","property":"background","value":"url('image;file}.png')","position":{"start":{"line":39,"column":3},"end":{"line":39,"column":37},"source":"input.css"}},{"type":"declaration","property":"--custom","value":"calc(100% - 20px)","position":{"start":{"line":40,"column":3},"end":{"line":40,"column":30},"source":"input.css"}}],"position":{"start":{"line":37,"column":1},"end":{"line":41,"column":2},"source":"input.css"}},{"type":"rule","selectors":["article"],"declarations":[{"type":"declaration","property":"color","value":"black","position":{"start":{"line":44,"column":3},"end":{"line":44,"column":15},"source":"input.css"}},{"type":"rule","selectors":["h1"],"declarations":[{"type":"declaration","property":"font-size","value":"2em","position":{"start":{"line":47,"column":5},"end":{"line":47,"column":19},"source":"input.css"}}],"position":{"start":{"line":46,"column":3},"end":{"line":48,"column":4},"source":"input.css"}},{"type":"rule","selectors":["p"],"declarations":[{"type":"declaration","property":"line-height","value":"1.5","position":{"start":{"line":51,"column":5},"end":{"line":51,"column":21},"source":"input.css"}},{"type":"rule","selectors":["a"],"declarations":[{"type":"declaration","property":"text-decoration","value":"none","position":{"start":{"line":54,"column":7},"end":{"line":54,"column":28},"source":"input.css"}}],"position":{"start":{"line":53,"column":5},"end":{"line":55,"column":6},"source":"input.css"}}],"position":{"start":{"line":50,"column":3},"end":{"line":56,"column":4},"source":"input.css"}}],"position":{"start":{"line":43,"column":1},"end":{"line":57,"column":2},"source":"input.css"}}],"parsingErrors":[]}} \ No newline at end of file diff --git a/test/cases/css-nesting/compressed.css b/test/cases/css-nesting/compressed.css new file mode 100644 index 00000000..0dd6a331 --- /dev/null +++ b/test/cases/css-nesting/compressed.css @@ -0,0 +1 @@ +.parent{color:red;.child{color:blue;}&:hover{color:green;}& .descendant{font-size:14px;}> .direct{margin:0;}+ .sibling{padding:10px;}.deeply-nested{font-weight:bold;.even-deeper{font-style:italic;}}@media (min-width: 768px){font-size:18px;}}.special-chars{content:"semicolon ; and brace }";background:url('image;file}.png');--custom:calc(100% - 20px);}article{color:black;h1{font-size:2em;}p{line-height:1.5;a{text-decoration:none;}}} \ No newline at end of file diff --git a/test/cases/css-nesting/input.css b/test/cases/css-nesting/input.css new file mode 100644 index 00000000..6ee8e470 --- /dev/null +++ b/test/cases/css-nesting/input.css @@ -0,0 +1,57 @@ +.parent { + color: red; + + .child { + color: blue; + } + + &:hover { + color: green; + } + + & .descendant { + font-size: 14px; + } + + > .direct { + margin: 0; + } + + + .sibling { + padding: 10px; + } + + .deeply-nested { + font-weight: bold; + + .even-deeper { + font-style: italic; + } + } + + @media (min-width: 768px) { + font-size: 18px; + } +} + +.special-chars { + content: "semicolon ; and brace }"; + background: url('image;file}.png'); + --custom: calc(100% - 20px); +} + +article { + color: black; + + h1 { + font-size: 2em; + } + + p { + line-height: 1.5; + + a { + text-decoration: none; + } + } +} diff --git a/test/cases/css-nesting/output.css b/test/cases/css-nesting/output.css new file mode 100644 index 00000000..8706c030 --- /dev/null +++ b/test/cases/css-nesting/output.css @@ -0,0 +1,46 @@ +.parent { + color: red; + .child { + color: blue; + } + &:hover { + color: green; + } + & .descendant { + font-size: 14px; + } + > .direct { + margin: 0; + } + + .sibling { + padding: 10px; + } + .deeply-nested { + font-weight: bold; + .even-deeper { + font-style: italic; + } + } + @media (min-width: 768px) { + font-size: 18px; + } +} + +.special-chars { + content: "semicolon ; and brace }"; + background: url('image;file}.png'); + --custom: calc(100% - 20px); +} + +article { + color: black; + h1 { + font-size: 2em; + } + p { + line-height: 1.5; + a { + text-decoration: none; + } + } +} \ No newline at end of file diff --git a/test/cases/generic-at-rules/ast.json b/test/cases/generic-at-rules/ast.json new file mode 100644 index 00000000..badb626f --- /dev/null +++ b/test/cases/generic-at-rules/ast.json @@ -0,0 +1 @@ +{"type":"stylesheet","stylesheet":{"source":"input.css","rules":[{"type":"at-rule","name":"property","prelude":"--my-color","rules":[{"type":"declaration","property":"syntax","value":"\"\"","position":{"start":{"line":2,"column":3},"end":{"line":2,"column":20},"source":"input.css"}},{"type":"declaration","property":"inherits","value":"false","position":{"start":{"line":3,"column":3},"end":{"line":3,"column":18},"source":"input.css"}},{"type":"declaration","property":"initial-value","value":"red","position":{"start":{"line":4,"column":3},"end":{"line":4,"column":21},"source":"input.css"}}],"position":{"start":{"line":1,"column":1},"end":{"line":5,"column":2},"source":"input.css"}},{"type":"at-rule","name":"counter-style","prelude":"thumbs","rules":[{"type":"declaration","property":"system","value":"cyclic","position":{"start":{"line":8,"column":3},"end":{"line":8,"column":17},"source":"input.css"}},{"type":"declaration","property":"symbols","value":"\"\\1F44D\"","position":{"start":{"line":9,"column":3},"end":{"line":9,"column":20},"source":"input.css"}},{"type":"declaration","property":"suffix","value":"\" \"","position":{"start":{"line":10,"column":3},"end":{"line":10,"column":14},"source":"input.css"}}],"position":{"start":{"line":7,"column":1},"end":{"line":11,"column":2},"source":"input.css"}},{"type":"at-rule","name":"font-feature-values","prelude":"Font One","rules":[{"type":"at-rule","name":"styleset","prelude":"","rules":[{"type":"declaration","property":"nice-style","value":"12","position":{"start":{"line":15,"column":5},"end":{"line":15,"column":19},"source":"input.css"}}],"position":{"start":{"line":14,"column":3},"end":{"line":16,"column":4},"source":"input.css"}}],"position":{"start":{"line":13,"column":1},"end":{"line":17,"column":2},"source":"input.css"}},{"type":"at-rule","name":"scope","prelude":"(.card) to (.card-body)","rules":[{"type":"rule","selectors":[":scope"],"declarations":[{"type":"declaration","property":"padding","value":"1rem","position":{"start":{"line":21,"column":5},"end":{"line":21,"column":18},"source":"input.css"}}],"position":{"start":{"line":20,"column":3},"end":{"line":22,"column":4},"source":"input.css"}},{"type":"rule","selectors":[".title"],"declarations":[{"type":"declaration","property":"font-size","value":"1.2em","position":{"start":{"line":25,"column":5},"end":{"line":25,"column":21},"source":"input.css"}}],"position":{"start":{"line":24,"column":3},"end":{"line":26,"column":4},"source":"input.css"}}],"position":{"start":{"line":19,"column":1},"end":{"line":27,"column":2},"source":"input.css"}},{"type":"at-rule","name":"view-transition","prelude":"","rules":[{"type":"declaration","property":"navigation","value":"auto","position":{"start":{"line":30,"column":3},"end":{"line":30,"column":19},"source":"input.css"}}],"position":{"start":{"line":29,"column":1},"end":{"line":31,"column":2},"source":"input.css"}},{"type":"at-rule","name":"position-try","prelude":"--my-fallback","rules":[{"type":"declaration","property":"top","value":"anchor(bottom)","position":{"start":{"line":34,"column":3},"end":{"line":34,"column":22},"source":"input.css"}},{"type":"declaration","property":"left","value":"anchor(left)","position":{"start":{"line":35,"column":3},"end":{"line":35,"column":21},"source":"input.css"}}],"position":{"start":{"line":33,"column":1},"end":{"line":36,"column":2},"source":"input.css"}}],"parsingErrors":[]}} \ No newline at end of file diff --git a/test/cases/generic-at-rules/compressed.css b/test/cases/generic-at-rules/compressed.css new file mode 100644 index 00000000..dc372284 --- /dev/null +++ b/test/cases/generic-at-rules/compressed.css @@ -0,0 +1 @@ +@property --my-color{syntax:"";inherits:false;initial-value:red;}@counter-style thumbs{system:cyclic;symbols:"\1F44D";suffix:" ";}@font-feature-values Font One{@styleset{nice-style:12;}}@scope (.card) to (.card-body){:scope{padding:1rem;}.title{font-size:1.2em;}}@view-transition{navigation:auto;}@position-try --my-fallback{top:anchor(bottom);left:anchor(left);} \ No newline at end of file diff --git a/test/cases/generic-at-rules/input.css b/test/cases/generic-at-rules/input.css new file mode 100644 index 00000000..99f0b0de --- /dev/null +++ b/test/cases/generic-at-rules/input.css @@ -0,0 +1,36 @@ +@property --my-color { + syntax: ""; + inherits: false; + initial-value: red; +} + +@counter-style thumbs { + system: cyclic; + symbols: "\1F44D"; + suffix: " "; +} + +@font-feature-values Font One { + @styleset { + nice-style: 12; + } +} + +@scope (.card) to (.card-body) { + :scope { + padding: 1rem; + } + + .title { + font-size: 1.2em; + } +} + +@view-transition { + navigation: auto; +} + +@position-try --my-fallback { + top: anchor(bottom); + left: anchor(left); +} diff --git a/test/cases/generic-at-rules/output.css b/test/cases/generic-at-rules/output.css new file mode 100644 index 00000000..363fb86b --- /dev/null +++ b/test/cases/generic-at-rules/output.css @@ -0,0 +1,36 @@ +@property --my-color { + syntax: ""; + inherits: false; + initial-value: red; +} + +@counter-style thumbs { + system: cyclic; + symbols: "\1F44D"; + suffix: " "; +} + +@font-feature-values Font One { + @styleset { + nice-style: 12; + } +} + +@scope (.card) to (.card-body) { + :scope { + padding: 1rem; + } + + .title { + font-size: 1.2em; + } +} + +@view-transition { + navigation: auto; +} + +@position-try --my-fallback { + top: anchor(bottom); + left: anchor(left); +} \ No newline at end of file From fe510b965881d0671c1dc0bbb945694af7638eda Mon Sep 17 00:00:00 2001 From: jkuehner Date: Tue, 17 Mar 2026 11:28:48 +0100 Subject: [PATCH 2/6] add more at rules --- src/parse/index.ts | 221 +++++++++++++++++++- src/stringify/compiler.ts | 153 ++++++++++++++ src/type.ts | 43 +++- test/cases/generic-at-rules/ast.json | 2 +- test/cases/page-margin-boxes/ast.json | 1 + test/cases/page-margin-boxes/compressed.css | 1 + test/cases/page-margin-boxes/input.css | 24 +++ test/cases/page-margin-boxes/output.css | 20 ++ 8 files changed, 456 insertions(+), 9 deletions(-) create mode 100644 test/cases/page-margin-boxes/ast.json create mode 100644 test/cases/page-margin-boxes/compressed.css create mode 100644 test/cases/page-margin-boxes/input.css create mode 100644 test/cases/page-margin-boxes/output.css diff --git a/src/parse/index.ts b/src/parse/index.ts index 0214553a..0cc601c3 100644 --- a/src/parse/index.ts +++ b/src/parse/index.ts @@ -6,10 +6,12 @@ import { type CssCommentAST, type CssCommonPositionAST, type CssContainerAST, + type CssCounterStyleAST, type CssCustomMediaAST, type CssDeclarationAST, type CssDocumentAST, type CssFontFaceAST, + type CssFontFeatureValuesAST, type CssGenericAtRuleAST, type CssHostAST, type CssImportAST, @@ -19,10 +21,14 @@ import { type CssMediaAST, type CssNamespaceAST, type CssPageAST, + type CssPositionTryAST, + type CssPropertyAST, type CssRuleAST, + type CssScopeAST, type CssStartingStyleAST, type CssStylesheetAST, type CssSupportsAST, + type CssViewTransitionAST, CssTypes, } from '../type'; import { @@ -688,14 +694,26 @@ export const parse = ( if (!open()) { return error("@page missing '{'"); } - let decls = comments(); + const decls: Array = []; + comments(decls); - // declarations - let decl: CssDeclarationAST | undefined = declaration(); - while (decl) { - decls.push(decl); - decls = decls.concat(comments()); - decl = declaration(); + // declarations and nested at-rules (margin boxes) + while (css.length && css.charAt(0) !== '}') { + if (css.charAt(0) === '@') { + const ar = atRule(); + if (ar) { + decls.push(ar); + comments(decls); + continue; + } + } + const decl = declaration(); + if (decl) { + decls.push(decl); + comments(decls); + continue; + } + break; } if (!close()) { @@ -775,6 +793,189 @@ export const parse = ( }); } + /** + * Parse @property. + */ + function atProperty(): CssPropertyAST | undefined { + const pos = position(); + const m = /^@property\s+(--[-\w]+)\s*/.exec(css); + if (!m) { + return; + } + const name = processMatch(m)[1]; + + if (!open()) { + return error("@property missing '{'"); + } + let decls = comments(); + let decl: CssDeclarationAST | undefined = declaration(); + while (decl) { + decls.push(decl); + decls = decls.concat(comments()); + decl = declaration(); + } + if (!close()) { + return error("@property missing '}'"); + } + + return pos({ + type: CssTypes.property, + name: name, + declarations: decls, + }); + } + + /** + * Parse @counter-style. + */ + function atCounterStyle(): CssCounterStyleAST | undefined { + const pos = position(); + const m = /^@counter-style\s+([-\w]+)\s*/.exec(css); + if (!m) { + return; + } + const name = processMatch(m)[1]; + + if (!open()) { + return error("@counter-style missing '{'"); + } + let decls = comments(); + let decl: CssDeclarationAST | undefined = declaration(); + while (decl) { + decls.push(decl); + decls = decls.concat(comments()); + decl = declaration(); + } + if (!close()) { + return error("@counter-style missing '}'"); + } + + return pos({ + type: CssTypes.counterStyle, + name: name, + declarations: decls, + }); + } + + /** + * Parse @font-feature-values. + */ + function atFontFeatureValues(): CssFontFeatureValuesAST | undefined { + const pos = position(); + const m = /^@font-feature-values\s+([^{]+)/.exec(css); + if (!m) { + return; + } + const fontFamily = trim(processMatch(m)[1]); + + if (!open()) { + return error("@font-feature-values missing '{'"); + } + + const style = rulesOrDeclarations(); + + if (!close()) { + return error("@font-feature-values missing '}'"); + } + + return pos({ + type: CssTypes.fontFeatureValues, + fontFamily: fontFamily, + rules: style, + }); + } + + /** + * Parse @scope. + */ + function atScope(): CssScopeAST | undefined { + const pos = position(); + const m = /^@scope\s*([^{]*)/.exec(css); + if (!m) { + return; + } + const scope = trim(processMatch(m)[1]); + + if (!open()) { + return error("@scope missing '{'"); + } + + const style = rulesOrDeclarations(); + + if (!close()) { + return error("@scope missing '}'"); + } + + return pos({ + type: CssTypes.scope, + scope: scope, + rules: style, + }); + } + + /** + * Parse @view-transition. + */ + function atViewTransition(): CssViewTransitionAST | undefined { + const pos = position(); + const m = /^@view-transition\s*/.exec(css); + if (!m) { + return; + } + processMatch(m); + + if (!open()) { + return error("@view-transition missing '{'"); + } + let decls = comments(); + let decl: CssDeclarationAST | undefined = declaration(); + while (decl) { + decls.push(decl); + decls = decls.concat(comments()); + decl = declaration(); + } + if (!close()) { + return error("@view-transition missing '}'"); + } + + return pos({ + type: CssTypes.viewTransition, + declarations: decls, + }); + } + + /** + * Parse @position-try. + */ + function atPositionTry(): CssPositionTryAST | undefined { + const pos = position(); + const m = /^@position-try\s+(--[-\w]+)\s*/.exec(css); + if (!m) { + return; + } + const name = processMatch(m)[1]; + + if (!open()) { + return error("@position-try missing '{'"); + } + let decls = comments(); + let decl: CssDeclarationAST | undefined = declaration(); + while (decl) { + decls.push(decl); + decls = decls.concat(comments()); + decl = declaration(); + } + if (!close()) { + return error("@position-try missing '}'"); + } + + return pos({ + type: CssTypes.positionTry, + name: name, + declarations: decls, + }); + } + /** * Parse starting style. */ @@ -913,9 +1114,15 @@ export const parse = ( atPage() || atHost() || atFontFace() || + atFontFeatureValues() || atContainer() || atStartingStyle() || atLayer() || + atProperty() || + atCounterStyle() || + atScope() || + atViewTransition() || + atPositionTry() || atGeneric() ); } diff --git a/src/stringify/compiler.ts b/src/stringify/compiler.ts index 5aad00c9..808722c8 100644 --- a/src/stringify/compiler.ts +++ b/src/stringify/compiler.ts @@ -4,10 +4,12 @@ import { type CssCommentAST, type CssCommonPositionAST, type CssContainerAST, + type CssCounterStyleAST, type CssCustomMediaAST, type CssDeclarationAST, type CssDocumentAST, type CssFontFaceAST, + type CssFontFeatureValuesAST, type CssGenericAtRuleAST, type CssHostAST, type CssImportAST, @@ -17,10 +19,14 @@ import { type CssMediaAST, type CssNamespaceAST, type CssPageAST, + type CssPositionTryAST, + type CssPropertyAST, type CssRuleAST, + type CssScopeAST, type CssStartingStyleAST, type CssStylesheetAST, type CssSupportsAST, + type CssViewTransitionAST, CssTypes, } from '../type'; @@ -77,12 +83,16 @@ class Compiler { return this.container(node); case CssTypes.charset: return this.charset(node); + case CssTypes.counterStyle: + return this.counterStyle(node); case CssTypes.document: return this.document(node); case CssTypes.customMedia: return this.customMedia(node); case CssTypes.fontFace: return this.fontFace(node); + case CssTypes.fontFeatureValues: + return this.fontFeatureValues(node); case CssTypes.host: return this.host(node); case CssTypes.import: @@ -99,10 +109,18 @@ class Compiler { return this.namespace(node); case CssTypes.page: return this.page(node); + case CssTypes.positionTry: + return this.positionTry(node); + case CssTypes.property: + return this.property(node); + case CssTypes.scope: + return this.scope(node); case CssTypes.startingStyle: return this.startingStyle(node); case CssTypes.supports: return this.supports(node); + case CssTypes.viewTransition: + return this.viewTransition(node); case CssTypes.atRule: return this.genericAtRule(node); } @@ -417,6 +435,141 @@ class Compiler { ); } + /** + * Visit @property node. + */ + property(node: CssPropertyAST) { + if (this.compress) { + return ( + this.emit(`@property ${node.name}`, node.position) + + this.emit('{') + + this.mapVisit(node.declarations) + + this.emit('}') + ); + } + return ( + this.emit(`@property ${node.name} `, node.position) + + this.emit('{\n') + + this.emit(this.indent(1)) + + this.mapVisit(node.declarations, '\n') + + this.emit(this.indent(-1)) + + this.emit('\n}') + ); + } + + /** + * Visit @counter-style node. + */ + counterStyle(node: CssCounterStyleAST) { + if (this.compress) { + return ( + this.emit(`@counter-style ${node.name}`, node.position) + + this.emit('{') + + this.mapVisit(node.declarations) + + this.emit('}') + ); + } + return ( + this.emit(`@counter-style ${node.name} `, node.position) + + this.emit('{\n') + + this.emit(this.indent(1)) + + this.mapVisit(node.declarations, '\n') + + this.emit(this.indent(-1)) + + this.emit('\n}') + ); + } + + /** + * Visit @font-feature-values node. + */ + fontFeatureValues(node: CssFontFeatureValuesAST) { + if (this.compress) { + return ( + this.emit( + `@font-feature-values ${node.fontFamily}`, + node.position, + ) + + this.emit('{') + + this.mapVisit(node.rules) + + this.emit('}') + ); + } + return ( + this.emit( + `${this.indent()}@font-feature-values ${node.fontFamily}`, + node.position, + ) + + this.emit(` {\n${this.indent(1)}`) + + this.mapVisit(node.rules, '\n\n') + + this.emit(`\n${this.indent(-1)}${this.indent()}}`) + ); + } + + /** + * Visit @scope node. + */ + scope(node: CssScopeAST) { + const prelude = node.scope ? ` ${node.scope}` : ''; + if (this.compress) { + return ( + this.emit(`@scope${prelude}`, node.position) + + this.emit('{') + + this.mapVisit(node.rules) + + this.emit('}') + ); + } + return ( + this.emit(`${this.indent()}@scope${prelude}`, node.position) + + this.emit(` {\n${this.indent(1)}`) + + this.mapVisit(node.rules, '\n\n') + + this.emit(`\n${this.indent(-1)}${this.indent()}}`) + ); + } + + /** + * Visit @view-transition node. + */ + viewTransition(node: CssViewTransitionAST) { + if (this.compress) { + return ( + this.emit('@view-transition', node.position) + + this.emit('{') + + this.mapVisit(node.declarations) + + this.emit('}') + ); + } + return ( + this.emit('@view-transition ', node.position) + + this.emit('{\n') + + this.emit(this.indent(1)) + + this.mapVisit(node.declarations, '\n') + + this.emit(this.indent(-1)) + + this.emit('\n}') + ); + } + + /** + * Visit @position-try node. + */ + positionTry(node: CssPositionTryAST) { + if (this.compress) { + return ( + this.emit(`@position-try ${node.name}`, node.position) + + this.emit('{') + + this.mapVisit(node.declarations) + + this.emit('}') + ); + } + return ( + this.emit(`@position-try ${node.name} `, node.position) + + this.emit('{\n') + + this.emit(this.indent(1)) + + this.mapVisit(node.declarations, '\n') + + this.emit(this.indent(-1)) + + this.emit('\n}') + ); + } + /** * Visit generic at-rule node (fallback for any unrecognized at-rule). */ diff --git a/src/type.ts b/src/type.ts index 3f015ff9..e4ee1e6d 100644 --- a/src/type.ts +++ b/src/type.ts @@ -9,9 +9,11 @@ export enum CssTypes { atRule = 'at-rule', container = 'container', charset = 'charset', + counterStyle = 'counter-style', document = 'document', customMedia = 'custom-media', fontFace = 'font-face', + fontFeatureValues = 'font-feature-values', host = 'host', import = 'import', keyframes = 'keyframes', @@ -20,8 +22,12 @@ export enum CssTypes { media = 'media', namespace = 'namespace', page = 'page', + positionTry = 'position-try', + property = 'property', + scope = 'scope', startingStyle = 'starting-style', supports = 'supports', + viewTransition = 'view-transition', } export type CssCommonAST = { @@ -119,7 +125,7 @@ export type CssNamespaceAST = CssCommonPositionAST & { export type CssPageAST = CssCommonPositionAST & { type: CssTypes.page; selectors: Array; - declarations: Array; + declarations: Array; }; export type CssSupportsAST = CssCommonPositionAST & { type: CssTypes.supports; @@ -132,6 +138,35 @@ export type CssStartingStyleAST = CssCommonPositionAST & { rules: Array; }; +export type CssCounterStyleAST = CssCommonPositionAST & { + type: CssTypes.counterStyle; + name: string; + declarations: Array; +}; +export type CssFontFeatureValuesAST = CssCommonPositionAST & { + type: CssTypes.fontFeatureValues; + fontFamily: string; + rules: Array; +}; +export type CssPositionTryAST = CssCommonPositionAST & { + type: CssTypes.positionTry; + name: string; + declarations: Array; +}; +export type CssPropertyAST = CssCommonPositionAST & { + type: CssTypes.property; + name: string; + declarations: Array; +}; +export type CssScopeAST = CssCommonPositionAST & { + type: CssTypes.scope; + scope: string; + rules: Array; +}; +export type CssViewTransitionAST = CssCommonPositionAST & { + type: CssTypes.viewTransition; + declarations: Array; +}; export type CssGenericAtRuleAST = CssCommonPositionAST & { type: CssTypes.atRule; name: string; @@ -144,9 +179,11 @@ export type CssAtRuleAST = | CssCommentAST | CssContainerAST | CssCharsetAST + | CssCounterStyleAST | CssCustomMediaAST | CssDocumentAST | CssFontFaceAST + | CssFontFeatureValuesAST | CssHostAST | CssImportAST | CssKeyframesAST @@ -154,8 +191,12 @@ export type CssAtRuleAST = | CssMediaAST | CssNamespaceAST | CssPageAST + | CssPositionTryAST + | CssPropertyAST + | CssScopeAST | CssSupportsAST | CssStartingStyleAST + | CssViewTransitionAST | CssGenericAtRuleAST; export type CssAllNodesAST = diff --git a/test/cases/generic-at-rules/ast.json b/test/cases/generic-at-rules/ast.json index badb626f..7fc4e04d 100644 --- a/test/cases/generic-at-rules/ast.json +++ b/test/cases/generic-at-rules/ast.json @@ -1 +1 @@ -{"type":"stylesheet","stylesheet":{"source":"input.css","rules":[{"type":"at-rule","name":"property","prelude":"--my-color","rules":[{"type":"declaration","property":"syntax","value":"\"\"","position":{"start":{"line":2,"column":3},"end":{"line":2,"column":20},"source":"input.css"}},{"type":"declaration","property":"inherits","value":"false","position":{"start":{"line":3,"column":3},"end":{"line":3,"column":18},"source":"input.css"}},{"type":"declaration","property":"initial-value","value":"red","position":{"start":{"line":4,"column":3},"end":{"line":4,"column":21},"source":"input.css"}}],"position":{"start":{"line":1,"column":1},"end":{"line":5,"column":2},"source":"input.css"}},{"type":"at-rule","name":"counter-style","prelude":"thumbs","rules":[{"type":"declaration","property":"system","value":"cyclic","position":{"start":{"line":8,"column":3},"end":{"line":8,"column":17},"source":"input.css"}},{"type":"declaration","property":"symbols","value":"\"\\1F44D\"","position":{"start":{"line":9,"column":3},"end":{"line":9,"column":20},"source":"input.css"}},{"type":"declaration","property":"suffix","value":"\" \"","position":{"start":{"line":10,"column":3},"end":{"line":10,"column":14},"source":"input.css"}}],"position":{"start":{"line":7,"column":1},"end":{"line":11,"column":2},"source":"input.css"}},{"type":"at-rule","name":"font-feature-values","prelude":"Font One","rules":[{"type":"at-rule","name":"styleset","prelude":"","rules":[{"type":"declaration","property":"nice-style","value":"12","position":{"start":{"line":15,"column":5},"end":{"line":15,"column":19},"source":"input.css"}}],"position":{"start":{"line":14,"column":3},"end":{"line":16,"column":4},"source":"input.css"}}],"position":{"start":{"line":13,"column":1},"end":{"line":17,"column":2},"source":"input.css"}},{"type":"at-rule","name":"scope","prelude":"(.card) to (.card-body)","rules":[{"type":"rule","selectors":[":scope"],"declarations":[{"type":"declaration","property":"padding","value":"1rem","position":{"start":{"line":21,"column":5},"end":{"line":21,"column":18},"source":"input.css"}}],"position":{"start":{"line":20,"column":3},"end":{"line":22,"column":4},"source":"input.css"}},{"type":"rule","selectors":[".title"],"declarations":[{"type":"declaration","property":"font-size","value":"1.2em","position":{"start":{"line":25,"column":5},"end":{"line":25,"column":21},"source":"input.css"}}],"position":{"start":{"line":24,"column":3},"end":{"line":26,"column":4},"source":"input.css"}}],"position":{"start":{"line":19,"column":1},"end":{"line":27,"column":2},"source":"input.css"}},{"type":"at-rule","name":"view-transition","prelude":"","rules":[{"type":"declaration","property":"navigation","value":"auto","position":{"start":{"line":30,"column":3},"end":{"line":30,"column":19},"source":"input.css"}}],"position":{"start":{"line":29,"column":1},"end":{"line":31,"column":2},"source":"input.css"}},{"type":"at-rule","name":"position-try","prelude":"--my-fallback","rules":[{"type":"declaration","property":"top","value":"anchor(bottom)","position":{"start":{"line":34,"column":3},"end":{"line":34,"column":22},"source":"input.css"}},{"type":"declaration","property":"left","value":"anchor(left)","position":{"start":{"line":35,"column":3},"end":{"line":35,"column":21},"source":"input.css"}}],"position":{"start":{"line":33,"column":1},"end":{"line":36,"column":2},"source":"input.css"}}],"parsingErrors":[]}} \ No newline at end of file +{"type":"stylesheet","stylesheet":{"source":"input.css","rules":[{"type":"property","name":"--my-color","declarations":[{"type":"declaration","property":"syntax","value":"\"\"","position":{"start":{"line":2,"column":3},"end":{"line":2,"column":20},"source":"input.css"}},{"type":"declaration","property":"inherits","value":"false","position":{"start":{"line":3,"column":3},"end":{"line":3,"column":18},"source":"input.css"}},{"type":"declaration","property":"initial-value","value":"red","position":{"start":{"line":4,"column":3},"end":{"line":4,"column":21},"source":"input.css"}}],"position":{"start":{"line":1,"column":1},"end":{"line":5,"column":2},"source":"input.css"}},{"type":"counter-style","name":"thumbs","declarations":[{"type":"declaration","property":"system","value":"cyclic","position":{"start":{"line":8,"column":3},"end":{"line":8,"column":17},"source":"input.css"}},{"type":"declaration","property":"symbols","value":"\"\\1F44D\"","position":{"start":{"line":9,"column":3},"end":{"line":9,"column":20},"source":"input.css"}},{"type":"declaration","property":"suffix","value":"\" \"","position":{"start":{"line":10,"column":3},"end":{"line":10,"column":14},"source":"input.css"}}],"position":{"start":{"line":7,"column":1},"end":{"line":11,"column":2},"source":"input.css"}},{"type":"font-feature-values","fontFamily":"Font One","rules":[{"type":"at-rule","name":"styleset","prelude":"","rules":[{"type":"declaration","property":"nice-style","value":"12","position":{"start":{"line":15,"column":5},"end":{"line":15,"column":19},"source":"input.css"}}],"position":{"start":{"line":14,"column":3},"end":{"line":16,"column":4},"source":"input.css"}}],"position":{"start":{"line":13,"column":1},"end":{"line":17,"column":2},"source":"input.css"}},{"type":"scope","scope":"(.card) to (.card-body)","rules":[{"type":"rule","selectors":[":scope"],"declarations":[{"type":"declaration","property":"padding","value":"1rem","position":{"start":{"line":21,"column":5},"end":{"line":21,"column":18},"source":"input.css"}}],"position":{"start":{"line":20,"column":3},"end":{"line":22,"column":4},"source":"input.css"}},{"type":"rule","selectors":[".title"],"declarations":[{"type":"declaration","property":"font-size","value":"1.2em","position":{"start":{"line":25,"column":5},"end":{"line":25,"column":21},"source":"input.css"}}],"position":{"start":{"line":24,"column":3},"end":{"line":26,"column":4},"source":"input.css"}}],"position":{"start":{"line":19,"column":1},"end":{"line":27,"column":2},"source":"input.css"}},{"type":"view-transition","declarations":[{"type":"declaration","property":"navigation","value":"auto","position":{"start":{"line":30,"column":3},"end":{"line":30,"column":19},"source":"input.css"}}],"position":{"start":{"line":29,"column":1},"end":{"line":31,"column":2},"source":"input.css"}},{"type":"position-try","name":"--my-fallback","declarations":[{"type":"declaration","property":"top","value":"anchor(bottom)","position":{"start":{"line":34,"column":3},"end":{"line":34,"column":22},"source":"input.css"}},{"type":"declaration","property":"left","value":"anchor(left)","position":{"start":{"line":35,"column":3},"end":{"line":35,"column":21},"source":"input.css"}}],"position":{"start":{"line":33,"column":1},"end":{"line":36,"column":2},"source":"input.css"}}],"parsingErrors":[]}} \ No newline at end of file diff --git a/test/cases/page-margin-boxes/ast.json b/test/cases/page-margin-boxes/ast.json new file mode 100644 index 00000000..db7f43ac --- /dev/null +++ b/test/cases/page-margin-boxes/ast.json @@ -0,0 +1 @@ +{"type":"stylesheet","stylesheet":{"source":"input.css","rules":[{"type":"page","selectors":[],"declarations":[{"type":"declaration","property":"margin","value":"2cm","position":{"start":{"line":2,"column":3},"end":{"line":2,"column":14},"source":"input.css"}},{"type":"at-rule","name":"top-center","prelude":"","rules":[{"type":"declaration","property":"content","value":"\"Page Title\"","position":{"start":{"line":5,"column":5},"end":{"line":5,"column":26},"source":"input.css"}}],"position":{"start":{"line":4,"column":3},"end":{"line":6,"column":4},"source":"input.css"}},{"type":"at-rule","name":"bottom-right","prelude":"","rules":[{"type":"declaration","property":"content","value":"counter(page)","position":{"start":{"line":9,"column":5},"end":{"line":9,"column":27},"source":"input.css"}}],"position":{"start":{"line":8,"column":3},"end":{"line":10,"column":4},"source":"input.css"}},{"type":"at-rule","name":"left-middle","prelude":"","rules":[{"type":"declaration","property":"content","value":"\"Side\"","position":{"start":{"line":13,"column":5},"end":{"line":13,"column":20},"source":"input.css"}},{"type":"declaration","property":"font-size","value":"10pt","position":{"start":{"line":14,"column":5},"end":{"line":14,"column":20},"source":"input.css"}}],"position":{"start":{"line":12,"column":3},"end":{"line":15,"column":4},"source":"input.css"}}],"position":{"start":{"line":1,"column":1},"end":{"line":16,"column":2},"source":"input.css"}},{"type":"page","selectors":[":first"],"declarations":[{"type":"declaration","property":"margin-top","value":"4cm","position":{"start":{"line":19,"column":3},"end":{"line":19,"column":18},"source":"input.css"}},{"type":"at-rule","name":"top-left","prelude":"","rules":[{"type":"declaration","property":"content","value":"none","position":{"start":{"line":22,"column":5},"end":{"line":22,"column":18},"source":"input.css"}}],"position":{"start":{"line":21,"column":3},"end":{"line":23,"column":4},"source":"input.css"}}],"position":{"start":{"line":18,"column":1},"end":{"line":24,"column":2},"source":"input.css"}}],"parsingErrors":[]}} \ No newline at end of file diff --git a/test/cases/page-margin-boxes/compressed.css b/test/cases/page-margin-boxes/compressed.css new file mode 100644 index 00000000..3ccca2b7 --- /dev/null +++ b/test/cases/page-margin-boxes/compressed.css @@ -0,0 +1 @@ +@page {margin:2cm;@top-center{content:"Page Title";}@bottom-right{content:counter(page);}@left-middle{content:"Side";font-size:10pt;}}@page :first{margin-top:4cm;@top-left{content:none;}} \ No newline at end of file diff --git a/test/cases/page-margin-boxes/input.css b/test/cases/page-margin-boxes/input.css new file mode 100644 index 00000000..643be94a --- /dev/null +++ b/test/cases/page-margin-boxes/input.css @@ -0,0 +1,24 @@ +@page { + margin: 2cm; + + @top-center { + content: "Page Title"; + } + + @bottom-right { + content: counter(page); + } + + @left-middle { + content: "Side"; + font-size: 10pt; + } +} + +@page :first { + margin-top: 4cm; + + @top-left { + content: none; + } +} diff --git a/test/cases/page-margin-boxes/output.css b/test/cases/page-margin-boxes/output.css new file mode 100644 index 00000000..b357684e --- /dev/null +++ b/test/cases/page-margin-boxes/output.css @@ -0,0 +1,20 @@ +@page { + margin: 2cm; + @top-center { + content: "Page Title"; + } + @bottom-right { + content: counter(page); + } + @left-middle { + content: "Side"; + font-size: 10pt; + } +} + +@page :first { + margin-top: 4cm; + @top-left { + content: none; + } +} \ No newline at end of file From 4bb17f0a1e19dbf63e36c085f5dc48c7f6c00ebe Mon Sep 17 00:00:00 2001 From: jkuehner Date: Tue, 17 Mar 2026 11:34:14 +0100 Subject: [PATCH 3/6] add more at rules support --- src/parse/index.ts | 56 +++++++++++++++++++ src/stringify/compiler.ts | 25 +++++++++ src/type.ts | 7 +++ test/cases/page-margin-boxes/ast.json | 2 +- test/cases/page-margin-boxes/compressed.css | 2 +- test/cases/page-margin-boxes/input.css | 59 ++++++++++++++++++--- test/cases/page-margin-boxes/output.css | 49 +++++++++++++---- 7 files changed, 181 insertions(+), 19 deletions(-) diff --git a/src/parse/index.ts b/src/parse/index.ts index 0cc601c3..fa43e71c 100644 --- a/src/parse/index.ts +++ b/src/parse/index.ts @@ -21,6 +21,7 @@ import { type CssMediaAST, type CssNamespaceAST, type CssPageAST, + type CssPageMarginBoxAST, type CssPositionTryAST, type CssPropertyAST, type CssRuleAST, @@ -678,6 +679,60 @@ export const parse = ( }); } + /** + * Parse @page margin box at-rules (@top-left, @bottom-right, @left-middle, etc.). + */ + const pageMarginBoxNames = [ + 'top-left-corner', + 'top-left', + 'top-center', + 'top-right', + 'top-right-corner', + 'bottom-left-corner', + 'bottom-left', + 'bottom-center', + 'bottom-right', + 'bottom-right-corner', + 'left-top', + 'left-middle', + 'left-bottom', + 'right-top', + 'right-middle', + 'right-bottom', + ]; + const pageMarginBoxRegex = new RegExp( + '^@(' + pageMarginBoxNames.join('|') + ')(?![\\w-])\\s*', + ); + + function atPageMarginBox(): CssPageMarginBoxAST | undefined { + const pos = position(); + const m = pageMarginBoxRegex.exec(css); + if (!m) { + return; + } + const name = processMatch(m)[1]; + + if (!open()) { + return error(`@${name} missing '{'`); + } + let decls = comments(); + let decl: CssDeclarationAST | undefined = declaration(); + while (decl) { + decls.push(decl); + decls = decls.concat(comments()); + decl = declaration(); + } + if (!close()) { + return error(`@${name} missing '}'`); + } + + return pos({ + type: CssTypes.pageMarginBox, + name: name, + declarations: decls, + }); + } + /** * Parse paged media. */ @@ -1123,6 +1178,7 @@ export const parse = ( atScope() || atViewTransition() || atPositionTry() || + atPageMarginBox() || atGeneric() ); } diff --git a/src/stringify/compiler.ts b/src/stringify/compiler.ts index 808722c8..7c94d0f3 100644 --- a/src/stringify/compiler.ts +++ b/src/stringify/compiler.ts @@ -19,6 +19,7 @@ import { type CssMediaAST, type CssNamespaceAST, type CssPageAST, + type CssPageMarginBoxAST, type CssPositionTryAST, type CssPropertyAST, type CssRuleAST, @@ -109,6 +110,8 @@ class Compiler { return this.namespace(node); case CssTypes.page: return this.page(node); + case CssTypes.pageMarginBox: + return this.pageMarginBox(node); case CssTypes.positionTry: return this.positionTry(node); case CssTypes.property: @@ -383,6 +386,28 @@ class Compiler { ); } + /** + * Visit @page margin box node (@top-left, @bottom-right, etc.). + */ + pageMarginBox(node: CssPageMarginBoxAST) { + if (this.compress) { + return ( + this.emit(`@${node.name}`, node.position) + + this.emit('{') + + this.mapVisit(node.declarations) + + this.emit('}') + ); + } + return ( + this.emit(`${this.indent()}@${node.name} `, node.position) + + this.emit('{\n') + + this.emit(this.indent(1)) + + this.mapVisit(node.declarations, '\n') + + this.emit(this.indent(-1)) + + this.emit(`\n${this.indent()}}`) + ); + } + /** * Visit font-face node. */ diff --git a/src/type.ts b/src/type.ts index e4ee1e6d..c3900463 100644 --- a/src/type.ts +++ b/src/type.ts @@ -22,6 +22,7 @@ export enum CssTypes { media = 'media', namespace = 'namespace', page = 'page', + pageMarginBox = 'page-margin-box', positionTry = 'position-try', property = 'property', scope = 'scope', @@ -167,6 +168,11 @@ export type CssViewTransitionAST = CssCommonPositionAST & { type: CssTypes.viewTransition; declarations: Array; }; +export type CssPageMarginBoxAST = CssCommonPositionAST & { + type: CssTypes.pageMarginBox; + name: string; + declarations: Array; +}; export type CssGenericAtRuleAST = CssCommonPositionAST & { type: CssTypes.atRule; name: string; @@ -191,6 +197,7 @@ export type CssAtRuleAST = | CssMediaAST | CssNamespaceAST | CssPageAST + | CssPageMarginBoxAST | CssPositionTryAST | CssPropertyAST | CssScopeAST diff --git a/test/cases/page-margin-boxes/ast.json b/test/cases/page-margin-boxes/ast.json index db7f43ac..fd1ebe15 100644 --- a/test/cases/page-margin-boxes/ast.json +++ b/test/cases/page-margin-boxes/ast.json @@ -1 +1 @@ -{"type":"stylesheet","stylesheet":{"source":"input.css","rules":[{"type":"page","selectors":[],"declarations":[{"type":"declaration","property":"margin","value":"2cm","position":{"start":{"line":2,"column":3},"end":{"line":2,"column":14},"source":"input.css"}},{"type":"at-rule","name":"top-center","prelude":"","rules":[{"type":"declaration","property":"content","value":"\"Page Title\"","position":{"start":{"line":5,"column":5},"end":{"line":5,"column":26},"source":"input.css"}}],"position":{"start":{"line":4,"column":3},"end":{"line":6,"column":4},"source":"input.css"}},{"type":"at-rule","name":"bottom-right","prelude":"","rules":[{"type":"declaration","property":"content","value":"counter(page)","position":{"start":{"line":9,"column":5},"end":{"line":9,"column":27},"source":"input.css"}}],"position":{"start":{"line":8,"column":3},"end":{"line":10,"column":4},"source":"input.css"}},{"type":"at-rule","name":"left-middle","prelude":"","rules":[{"type":"declaration","property":"content","value":"\"Side\"","position":{"start":{"line":13,"column":5},"end":{"line":13,"column":20},"source":"input.css"}},{"type":"declaration","property":"font-size","value":"10pt","position":{"start":{"line":14,"column":5},"end":{"line":14,"column":20},"source":"input.css"}}],"position":{"start":{"line":12,"column":3},"end":{"line":15,"column":4},"source":"input.css"}}],"position":{"start":{"line":1,"column":1},"end":{"line":16,"column":2},"source":"input.css"}},{"type":"page","selectors":[":first"],"declarations":[{"type":"declaration","property":"margin-top","value":"4cm","position":{"start":{"line":19,"column":3},"end":{"line":19,"column":18},"source":"input.css"}},{"type":"at-rule","name":"top-left","prelude":"","rules":[{"type":"declaration","property":"content","value":"none","position":{"start":{"line":22,"column":5},"end":{"line":22,"column":18},"source":"input.css"}}],"position":{"start":{"line":21,"column":3},"end":{"line":23,"column":4},"source":"input.css"}}],"position":{"start":{"line":18,"column":1},"end":{"line":24,"column":2},"source":"input.css"}}],"parsingErrors":[]}} \ No newline at end of file +{"type":"stylesheet","stylesheet":{"source":"input.css","rules":[{"type":"page","selectors":[],"declarations":[{"type":"declaration","property":"margin","value":"2cm","position":{"start":{"line":2,"column":3},"end":{"line":2,"column":14},"source":"input.css"}},{"type":"page-margin-box","name":"top-left-corner","declarations":[{"type":"declaration","property":"content","value":"\"\"","position":{"start":{"line":5,"column":5},"end":{"line":5,"column":16},"source":"input.css"}}],"position":{"start":{"line":4,"column":3},"end":{"line":6,"column":4},"source":"input.css"}},{"type":"page-margin-box","name":"top-left","declarations":[{"type":"declaration","property":"content","value":"\"Header Left\"","position":{"start":{"line":9,"column":5},"end":{"line":9,"column":27},"source":"input.css"}}],"position":{"start":{"line":8,"column":3},"end":{"line":10,"column":4},"source":"input.css"}},{"type":"page-margin-box","name":"top-center","declarations":[{"type":"declaration","property":"content","value":"\"Page Title\"","position":{"start":{"line":13,"column":5},"end":{"line":13,"column":26},"source":"input.css"}}],"position":{"start":{"line":12,"column":3},"end":{"line":14,"column":4},"source":"input.css"}},{"type":"page-margin-box","name":"top-right","declarations":[{"type":"declaration","property":"content","value":"\"Header Right\"","position":{"start":{"line":17,"column":5},"end":{"line":17,"column":28},"source":"input.css"}}],"position":{"start":{"line":16,"column":3},"end":{"line":18,"column":4},"source":"input.css"}},{"type":"page-margin-box","name":"top-right-corner","declarations":[{"type":"declaration","property":"content","value":"\"\"","position":{"start":{"line":21,"column":5},"end":{"line":21,"column":16},"source":"input.css"}}],"position":{"start":{"line":20,"column":3},"end":{"line":22,"column":4},"source":"input.css"}},{"type":"page-margin-box","name":"bottom-left-corner","declarations":[{"type":"declaration","property":"content","value":"\"\"","position":{"start":{"line":25,"column":5},"end":{"line":25,"column":16},"source":"input.css"}}],"position":{"start":{"line":24,"column":3},"end":{"line":26,"column":4},"source":"input.css"}},{"type":"page-margin-box","name":"bottom-left","declarations":[{"type":"declaration","property":"content","value":"\"Footer Left\"","position":{"start":{"line":29,"column":5},"end":{"line":29,"column":27},"source":"input.css"}}],"position":{"start":{"line":28,"column":3},"end":{"line":30,"column":4},"source":"input.css"}},{"type":"page-margin-box","name":"bottom-center","declarations":[{"type":"declaration","property":"content","value":"counter(page)","position":{"start":{"line":33,"column":5},"end":{"line":33,"column":27},"source":"input.css"}}],"position":{"start":{"line":32,"column":3},"end":{"line":34,"column":4},"source":"input.css"}},{"type":"page-margin-box","name":"bottom-right","declarations":[{"type":"declaration","property":"content","value":"\"Footer Right\"","position":{"start":{"line":37,"column":5},"end":{"line":37,"column":28},"source":"input.css"}}],"position":{"start":{"line":36,"column":3},"end":{"line":38,"column":4},"source":"input.css"}},{"type":"page-margin-box","name":"bottom-right-corner","declarations":[{"type":"declaration","property":"content","value":"\"\"","position":{"start":{"line":41,"column":5},"end":{"line":41,"column":16},"source":"input.css"}}],"position":{"start":{"line":40,"column":3},"end":{"line":42,"column":4},"source":"input.css"}},{"type":"page-margin-box","name":"left-top","declarations":[{"type":"declaration","property":"content","value":"\"LT\"","position":{"start":{"line":45,"column":5},"end":{"line":45,"column":18},"source":"input.css"}}],"position":{"start":{"line":44,"column":3},"end":{"line":46,"column":4},"source":"input.css"}},{"type":"page-margin-box","name":"left-middle","declarations":[{"type":"declaration","property":"content","value":"\"LM\"","position":{"start":{"line":49,"column":5},"end":{"line":49,"column":18},"source":"input.css"}}],"position":{"start":{"line":48,"column":3},"end":{"line":50,"column":4},"source":"input.css"}},{"type":"page-margin-box","name":"left-bottom","declarations":[{"type":"declaration","property":"content","value":"\"LB\"","position":{"start":{"line":53,"column":5},"end":{"line":53,"column":18},"source":"input.css"}}],"position":{"start":{"line":52,"column":3},"end":{"line":54,"column":4},"source":"input.css"}},{"type":"page-margin-box","name":"right-top","declarations":[{"type":"declaration","property":"content","value":"\"RT\"","position":{"start":{"line":57,"column":5},"end":{"line":57,"column":18},"source":"input.css"}}],"position":{"start":{"line":56,"column":3},"end":{"line":58,"column":4},"source":"input.css"}},{"type":"page-margin-box","name":"right-middle","declarations":[{"type":"declaration","property":"content","value":"\"RM\"","position":{"start":{"line":61,"column":5},"end":{"line":61,"column":18},"source":"input.css"}}],"position":{"start":{"line":60,"column":3},"end":{"line":62,"column":4},"source":"input.css"}},{"type":"page-margin-box","name":"right-bottom","declarations":[{"type":"declaration","property":"content","value":"\"RB\"","position":{"start":{"line":65,"column":5},"end":{"line":65,"column":18},"source":"input.css"}}],"position":{"start":{"line":64,"column":3},"end":{"line":66,"column":4},"source":"input.css"}}],"position":{"start":{"line":1,"column":1},"end":{"line":67,"column":2},"source":"input.css"}}],"parsingErrors":[]}} \ No newline at end of file diff --git a/test/cases/page-margin-boxes/compressed.css b/test/cases/page-margin-boxes/compressed.css index 3ccca2b7..7707f66b 100644 --- a/test/cases/page-margin-boxes/compressed.css +++ b/test/cases/page-margin-boxes/compressed.css @@ -1 +1 @@ -@page {margin:2cm;@top-center{content:"Page Title";}@bottom-right{content:counter(page);}@left-middle{content:"Side";font-size:10pt;}}@page :first{margin-top:4cm;@top-left{content:none;}} \ No newline at end of file +@page {margin:2cm;@top-left-corner{content:"";}@top-left{content:"Header Left";}@top-center{content:"Page Title";}@top-right{content:"Header Right";}@top-right-corner{content:"";}@bottom-left-corner{content:"";}@bottom-left{content:"Footer Left";}@bottom-center{content:counter(page);}@bottom-right{content:"Footer Right";}@bottom-right-corner{content:"";}@left-top{content:"LT";}@left-middle{content:"LM";}@left-bottom{content:"LB";}@right-top{content:"RT";}@right-middle{content:"RM";}@right-bottom{content:"RB";}} \ No newline at end of file diff --git a/test/cases/page-margin-boxes/input.css b/test/cases/page-margin-boxes/input.css index 643be94a..b2e3d453 100644 --- a/test/cases/page-margin-boxes/input.css +++ b/test/cases/page-margin-boxes/input.css @@ -1,24 +1,67 @@ @page { margin: 2cm; + @top-left-corner { + content: ""; + } + + @top-left { + content: "Header Left"; + } + @top-center { content: "Page Title"; } - @bottom-right { + @top-right { + content: "Header Right"; + } + + @top-right-corner { + content: ""; + } + + @bottom-left-corner { + content: ""; + } + + @bottom-left { + content: "Footer Left"; + } + + @bottom-center { content: counter(page); } + @bottom-right { + content: "Footer Right"; + } + + @bottom-right-corner { + content: ""; + } + + @left-top { + content: "LT"; + } + @left-middle { - content: "Side"; - font-size: 10pt; + content: "LM"; } -} -@page :first { - margin-top: 4cm; + @left-bottom { + content: "LB"; + } - @top-left { - content: none; + @right-top { + content: "RT"; + } + + @right-middle { + content: "RM"; + } + + @right-bottom { + content: "RB"; } } diff --git a/test/cases/page-margin-boxes/output.css b/test/cases/page-margin-boxes/output.css index b357684e..bd659725 100644 --- a/test/cases/page-margin-boxes/output.css +++ b/test/cases/page-margin-boxes/output.css @@ -1,20 +1,51 @@ @page { margin: 2cm; + @top-left-corner { + content: ""; + } + @top-left { + content: "Header Left"; + } @top-center { content: "Page Title"; } - @bottom-right { + @top-right { + content: "Header Right"; + } + @top-right-corner { + content: ""; + } + @bottom-left-corner { + content: ""; + } + @bottom-left { + content: "Footer Left"; + } + @bottom-center { content: counter(page); } + @bottom-right { + content: "Footer Right"; + } + @bottom-right-corner { + content: ""; + } + @left-top { + content: "LT"; + } @left-middle { - content: "Side"; - font-size: 10pt; + content: "LM"; } -} - -@page :first { - margin-top: 4cm; - @top-left { - content: none; + @left-bottom { + content: "LB"; + } + @right-top { + content: "RT"; + } + @right-middle { + content: "RM"; + } + @right-bottom { + content: "RB"; } } \ No newline at end of file From b782d3aea951f902f28b8d87f2abe4472d885910 Mon Sep 17 00:00:00 2001 From: jkuehner Date: Tue, 17 Mar 2026 11:46:04 +0100 Subject: [PATCH 4/6] more tests for the parser --- src/parse/index.ts | 10 +- test/cases/complex-nesting/ast.json | 1 + test/cases/complex-nesting/compressed.css | 1 + test/cases/complex-nesting/input.css | 207 ++++++++++++++++++++ test/cases/complex-nesting/output.css | 176 +++++++++++++++++ test/cases/container-queries/ast.json | 1 + test/cases/container-queries/compressed.css | 1 + test/cases/container-queries/input.css | 102 ++++++++++ test/cases/container-queries/output.css | 94 +++++++++ test/cases/special-strings/ast.json | 1 + test/cases/special-strings/compressed.css | 3 + test/cases/special-strings/input.css | 88 +++++++++ test/cases/special-strings/output.css | 82 ++++++++ 13 files changed, 763 insertions(+), 4 deletions(-) create mode 100644 test/cases/complex-nesting/ast.json create mode 100644 test/cases/complex-nesting/compressed.css create mode 100644 test/cases/complex-nesting/input.css create mode 100644 test/cases/complex-nesting/output.css create mode 100644 test/cases/container-queries/ast.json create mode 100644 test/cases/container-queries/compressed.css create mode 100644 test/cases/container-queries/input.css create mode 100644 test/cases/container-queries/output.css create mode 100644 test/cases/special-strings/ast.json create mode 100644 test/cases/special-strings/compressed.css create mode 100644 test/cases/special-strings/input.css create mode 100644 test/cases/special-strings/output.css diff --git a/src/parse/index.ts b/src/parse/index.ts index fa43e71c..ca5a9c7e 100644 --- a/src/parse/index.ts +++ b/src/parse/index.ts @@ -228,14 +228,16 @@ export const parse = ( * Parse selector. */ function selector() { - const m = /^([^{]+)/.exec(css); - if (!m) { + const bracePos = indexOfArrayWithBracketAndQuoteSupport(css, ['{']); + if (bracePos === -1 || bracePos === 0) { return; } - processMatch(m); + const selectorStr = css.substring(0, bracePos); + const fakeMatch = [selectorStr] as unknown as RegExpExecArray; + processMatch(fakeMatch); // remove comment in selector; - const res = trim(m[0]).replace(commentRegex, ''); + const res = trim(selectorStr).replace(commentRegex, ''); return splitWithBracketAndQuoteSupport(res, [',']).map((v) => trim(v)); } diff --git a/test/cases/complex-nesting/ast.json b/test/cases/complex-nesting/ast.json new file mode 100644 index 00000000..1cd72d33 --- /dev/null +++ b/test/cases/complex-nesting/ast.json @@ -0,0 +1 @@ +{"type":"stylesheet","stylesheet":{"source":"input.css","rules":[{"type":"rule","selectors":[".dashboard"],"declarations":[{"type":"declaration","property":"display","value":"grid","position":{"start":{"line":2,"column":3},"end":{"line":2,"column":16},"source":"input.css"}},{"type":"declaration","property":"gap","value":"1rem","position":{"start":{"line":3,"column":3},"end":{"line":3,"column":12},"source":"input.css"}},{"type":"rule","selectors":[".sidebar"],"declarations":[{"type":"declaration","property":"width","value":"250px","position":{"start":{"line":6,"column":5},"end":{"line":6,"column":17},"source":"input.css"}},{"type":"rule","selectors":["nav"],"declarations":[{"type":"declaration","property":"padding","value":"1rem","position":{"start":{"line":9,"column":7},"end":{"line":9,"column":20},"source":"input.css"}},{"type":"rule","selectors":["a"],"declarations":[{"type":"declaration","property":"color","value":"inherit","position":{"start":{"line":12,"column":9},"end":{"line":12,"column":23},"source":"input.css"}},{"type":"declaration","property":"text-decoration","value":"none","position":{"start":{"line":13,"column":9},"end":{"line":13,"column":30},"source":"input.css"}},{"type":"rule","selectors":["&:hover"],"declarations":[{"type":"declaration","property":"text-decoration","value":"underline","position":{"start":{"line":16,"column":11},"end":{"line":16,"column":37},"source":"input.css"}}],"position":{"start":{"line":15,"column":9},"end":{"line":17,"column":10},"source":"input.css"}},{"type":"rule","selectors":["&::after"],"declarations":[{"type":"declaration","property":"content","value":"\" \\2192\"","position":{"start":{"line":20,"column":11},"end":{"line":20,"column":28},"source":"input.css"}}],"position":{"start":{"line":19,"column":9},"end":{"line":21,"column":10},"source":"input.css"}},{"type":"rule","selectors":["&.active"],"declarations":[{"type":"declaration","property":"font-weight","value":"bold","position":{"start":{"line":24,"column":11},"end":{"line":24,"column":28},"source":"input.css"}},{"type":"rule","selectors":["&::before"],"declarations":[{"type":"declaration","property":"content","value":"\"\\25B6\"","position":{"start":{"line":27,"column":13},"end":{"line":27,"column":29},"source":"input.css"}},{"type":"declaration","property":"margin-right","value":"0.5em","position":{"start":{"line":28,"column":13},"end":{"line":28,"column":32},"source":"input.css"}}],"position":{"start":{"line":26,"column":11},"end":{"line":29,"column":12},"source":"input.css"}}],"position":{"start":{"line":23,"column":9},"end":{"line":30,"column":10},"source":"input.css"}}],"position":{"start":{"line":11,"column":7},"end":{"line":31,"column":8},"source":"input.css"}},{"type":"rule","selectors":["ul"],"declarations":[{"type":"declaration","property":"list-style","value":"none","position":{"start":{"line":34,"column":9},"end":{"line":34,"column":25},"source":"input.css"}},{"type":"declaration","property":"padding","value":"0","position":{"start":{"line":35,"column":9},"end":{"line":35,"column":19},"source":"input.css"}},{"type":"rule","selectors":["> li"],"declarations":[{"type":"declaration","property":"margin-bottom","value":"0.5rem","position":{"start":{"line":38,"column":11},"end":{"line":38,"column":32},"source":"input.css"}},{"type":"rule","selectors":["+ li"],"declarations":[{"type":"declaration","property":"border-top","value":"1px solid #eee","position":{"start":{"line":41,"column":13},"end":{"line":41,"column":39},"source":"input.css"}},{"type":"declaration","property":"padding-top","value":"0.5rem","position":{"start":{"line":42,"column":13},"end":{"line":42,"column":32},"source":"input.css"}}],"position":{"start":{"line":40,"column":11},"end":{"line":43,"column":12},"source":"input.css"}}],"position":{"start":{"line":37,"column":9},"end":{"line":44,"column":10},"source":"input.css"}}],"position":{"start":{"line":33,"column":7},"end":{"line":45,"column":8},"source":"input.css"}}],"position":{"start":{"line":8,"column":5},"end":{"line":46,"column":6},"source":"input.css"}}],"position":{"start":{"line":5,"column":3},"end":{"line":47,"column":4},"source":"input.css"}},{"type":"rule","selectors":[".main-content"],"declarations":[{"type":"declaration","property":"flex","value":"1","position":{"start":{"line":50,"column":5},"end":{"line":50,"column":12},"source":"input.css"}},{"type":"media","media":"(min-width: 768px)","rules":[{"type":"declaration","property":"padding","value":"2rem","position":{"start":{"line":53,"column":7},"end":{"line":53,"column":20},"source":"input.css"}}],"position":{"start":{"line":52,"column":5},"end":{"line":54,"column":6},"source":"input.css"}},{"type":"media","media":"(min-width: 1024px)","rules":[{"type":"declaration","property":"padding","value":"3rem","position":{"start":{"line":57,"column":7},"end":{"line":57,"column":20},"source":"input.css"}},{"type":"rule","selectors":[".hero"],"declarations":[{"type":"declaration","property":"font-size","value":"2rem","position":{"start":{"line":60,"column":9},"end":{"line":60,"column":24},"source":"input.css"}}],"position":{"start":{"line":59,"column":7},"end":{"line":61,"column":8},"source":"input.css"}}],"position":{"start":{"line":56,"column":5},"end":{"line":62,"column":6},"source":"input.css"}},{"type":"supports","supports":"(container-type: inline-size)","rules":[{"type":"declaration","property":"container-type","value":"inline-size","position":{"start":{"line":65,"column":7},"end":{"line":65,"column":34},"source":"input.css"}},{"type":"container","container":"(min-width: 500px)","rules":[{"type":"rule","selectors":[".card-grid"],"declarations":[{"type":"declaration","property":"grid-template-columns","value":"repeat(2, 1fr)","position":{"start":{"line":69,"column":11},"end":{"line":69,"column":48},"source":"input.css"}}],"position":{"start":{"line":68,"column":9},"end":{"line":70,"column":10},"source":"input.css"}}],"position":{"start":{"line":67,"column":7},"end":{"line":71,"column":8},"source":"input.css"}}],"position":{"start":{"line":64,"column":5},"end":{"line":72,"column":6},"source":"input.css"}},{"type":"rule","selectors":["h1"],"declarations":[{"type":"declaration","property":"font-size","value":"1.5rem","position":{"start":{"line":75,"column":7},"end":{"line":75,"column":24},"source":"input.css"}},{"type":"declaration","property":"margin-bottom","value":"1rem","position":{"start":{"line":76,"column":7},"end":{"line":76,"column":26},"source":"input.css"}},{"type":"rule","selectors":["~ p"],"declarations":[{"type":"declaration","property":"color","value":"#666","position":{"start":{"line":79,"column":9},"end":{"line":79,"column":20},"source":"input.css"}}],"position":{"start":{"line":78,"column":7},"end":{"line":80,"column":8},"source":"input.css"}}],"position":{"start":{"line":74,"column":5},"end":{"line":81,"column":6},"source":"input.css"}},{"type":"rule","selectors":[".card"],"declarations":[{"type":"declaration","property":"border","value":"1px solid #ddd","position":{"start":{"line":84,"column":7},"end":{"line":84,"column":29},"source":"input.css"}},{"type":"declaration","property":"border-radius","value":"8px","position":{"start":{"line":85,"column":7},"end":{"line":85,"column":25},"source":"input.css"}},{"type":"declaration","property":"padding","value":"1rem","position":{"start":{"line":86,"column":7},"end":{"line":86,"column":20},"source":"input.css"}},{"type":"rule","selectors":["&:first-child"],"declarations":[{"type":"declaration","property":"border-color","value":"blue","position":{"start":{"line":89,"column":9},"end":{"line":89,"column":27},"source":"input.css"}}],"position":{"start":{"line":88,"column":7},"end":{"line":90,"column":8},"source":"input.css"}},{"type":"rule","selectors":["&:not(:last-child)"],"declarations":[{"type":"declaration","property":"margin-bottom","value":"1rem","position":{"start":{"line":93,"column":9},"end":{"line":93,"column":28},"source":"input.css"}}],"position":{"start":{"line":92,"column":7},"end":{"line":94,"column":8},"source":"input.css"}},{"type":"rule","selectors":[".card-header"],"declarations":[{"type":"declaration","property":"font-weight","value":"bold","position":{"start":{"line":97,"column":9},"end":{"line":97,"column":26},"source":"input.css"}},{"type":"rule","selectors":[".card-title"],"declarations":[{"type":"declaration","property":"font-size","value":"1.2em","position":{"start":{"line":100,"column":11},"end":{"line":100,"column":27},"source":"input.css"}}],"position":{"start":{"line":99,"column":9},"end":{"line":101,"column":10},"source":"input.css"}},{"type":"rule","selectors":[".card-subtitle"],"declarations":[{"type":"declaration","property":"color","value":"#999","position":{"start":{"line":104,"column":11},"end":{"line":104,"column":22},"source":"input.css"}},{"type":"declaration","property":"font-size","value":"0.9em","position":{"start":{"line":105,"column":11},"end":{"line":105,"column":27},"source":"input.css"}}],"position":{"start":{"line":103,"column":9},"end":{"line":106,"column":10},"source":"input.css"}}],"position":{"start":{"line":96,"column":7},"end":{"line":107,"column":8},"source":"input.css"}},{"type":"rule","selectors":[".card-body"],"declarations":[{"type":"declaration","property":"margin-top","value":"0.5rem","position":{"start":{"line":110,"column":9},"end":{"line":110,"column":27},"source":"input.css"}}],"position":{"start":{"line":109,"column":7},"end":{"line":111,"column":8},"source":"input.css"}},{"type":"rule","selectors":[".card-footer"],"declarations":[{"type":"declaration","property":"margin-top","value":"1rem","position":{"start":{"line":114,"column":9},"end":{"line":114,"column":25},"source":"input.css"}},{"type":"declaration","property":"border-top","value":"1px solid #eee","position":{"start":{"line":115,"column":9},"end":{"line":115,"column":35},"source":"input.css"}},{"type":"declaration","property":"padding-top","value":"0.5rem","position":{"start":{"line":116,"column":9},"end":{"line":116,"column":28},"source":"input.css"}},{"type":"rule","selectors":["button"],"declarations":[{"type":"declaration","property":"cursor","value":"pointer","position":{"start":{"line":119,"column":11},"end":{"line":119,"column":26},"source":"input.css"}},{"type":"rule","selectors":["&:disabled"],"declarations":[{"type":"declaration","property":"opacity","value":"0.5","position":{"start":{"line":122,"column":13},"end":{"line":122,"column":25},"source":"input.css"}},{"type":"declaration","property":"cursor","value":"not-allowed","position":{"start":{"line":123,"column":13},"end":{"line":123,"column":32},"source":"input.css"}}],"position":{"start":{"line":121,"column":11},"end":{"line":124,"column":12},"source":"input.css"}}],"position":{"start":{"line":118,"column":9},"end":{"line":125,"column":10},"source":"input.css"}}],"position":{"start":{"line":113,"column":7},"end":{"line":126,"column":8},"source":"input.css"}}],"position":{"start":{"line":83,"column":5},"end":{"line":127,"column":6},"source":"input.css"}}],"position":{"start":{"line":49,"column":3},"end":{"line":128,"column":4},"source":"input.css"}}],"position":{"start":{"line":1,"column":1},"end":{"line":129,"column":2},"source":"input.css"}},{"type":"layer","layer":"base","rules":[{"type":"layer","layer":"reset","rules":[{"type":"rule","selectors":["*","*::before","*::after"],"declarations":[{"type":"declaration","property":"box-sizing","value":"border-box","position":{"start":{"line":134,"column":7},"end":{"line":134,"column":29},"source":"input.css"}},{"type":"declaration","property":"margin","value":"0","position":{"start":{"line":135,"column":7},"end":{"line":135,"column":16},"source":"input.css"}},{"type":"declaration","property":"padding","value":"0","position":{"start":{"line":136,"column":7},"end":{"line":136,"column":17},"source":"input.css"}}],"position":{"start":{"line":133,"column":5},"end":{"line":137,"column":6},"source":"input.css"}}],"position":{"start":{"line":132,"column":3},"end":{"line":138,"column":4},"source":"input.css"}},{"type":"layer","layer":"typography","rules":[{"type":"rule","selectors":["body"],"declarations":[{"type":"declaration","property":"font-family","value":"system-ui, sans-serif","position":{"start":{"line":142,"column":7},"end":{"line":142,"column":41},"source":"input.css"}},{"type":"declaration","property":"line-height","value":"1.5","position":{"start":{"line":143,"column":7},"end":{"line":143,"column":23},"source":"input.css"}},{"type":"media","media":"(prefers-color-scheme: dark)","rules":[{"type":"declaration","property":"color","value":"#f0f0f0","position":{"start":{"line":146,"column":9},"end":{"line":146,"column":23},"source":"input.css"}},{"type":"declaration","property":"background","value":"#1a1a1a","position":{"start":{"line":147,"column":9},"end":{"line":147,"column":28},"source":"input.css"}}],"position":{"start":{"line":145,"column":7},"end":{"line":148,"column":8},"source":"input.css"}}],"position":{"start":{"line":141,"column":5},"end":{"line":149,"column":6},"source":"input.css"}}],"position":{"start":{"line":140,"column":3},"end":{"line":150,"column":4},"source":"input.css"}}],"position":{"start":{"line":131,"column":1},"end":{"line":151,"column":2},"source":"input.css"}},{"type":"scope","scope":"(.theme-dark) to (.theme-light)","rules":[{"type":"rule","selectors":[":scope"],"declarations":[{"type":"declaration","property":"color","value":"white","position":{"start":{"line":155,"column":5},"end":{"line":155,"column":17},"source":"input.css"}},{"type":"declaration","property":"background","value":"#333","position":{"start":{"line":156,"column":5},"end":{"line":156,"column":21},"source":"input.css"}},{"type":"rule","selectors":["a"],"declarations":[{"type":"declaration","property":"color","value":"lightblue","position":{"start":{"line":159,"column":7},"end":{"line":159,"column":23},"source":"input.css"}},{"type":"rule","selectors":["&:visited"],"declarations":[{"type":"declaration","property":"color","value":"plum","position":{"start":{"line":162,"column":9},"end":{"line":162,"column":20},"source":"input.css"}}],"position":{"start":{"line":161,"column":7},"end":{"line":163,"column":8},"source":"input.css"}}],"position":{"start":{"line":158,"column":5},"end":{"line":164,"column":6},"source":"input.css"}}],"position":{"start":{"line":154,"column":3},"end":{"line":165,"column":4},"source":"input.css"}},{"type":"rule","selectors":[".card"],"declarations":[{"type":"declaration","property":"background","value":"#444","position":{"start":{"line":168,"column":5},"end":{"line":168,"column":21},"source":"input.css"}},{"type":"declaration","property":"border-color","value":"#555","position":{"start":{"line":169,"column":5},"end":{"line":169,"column":23},"source":"input.css"}},{"type":"media","media":"(prefers-contrast: high)","rules":[{"type":"declaration","property":"border-width","value":"2px","position":{"start":{"line":172,"column":7},"end":{"line":172,"column":24},"source":"input.css"}},{"type":"declaration","property":"border-color","value":"white","position":{"start":{"line":173,"column":7},"end":{"line":173,"column":26},"source":"input.css"}}],"position":{"start":{"line":171,"column":5},"end":{"line":174,"column":6},"source":"input.css"}}],"position":{"start":{"line":167,"column":3},"end":{"line":175,"column":4},"source":"input.css"}}],"position":{"start":{"line":153,"column":1},"end":{"line":176,"column":2},"source":"input.css"}},{"type":"media","media":"print","rules":[{"type":"page","selectors":[],"declarations":[{"type":"declaration","property":"margin","value":"2cm","position":{"start":{"line":180,"column":5},"end":{"line":180,"column":16},"source":"input.css"}},{"type":"page-margin-box","name":"top-center","declarations":[{"type":"declaration","property":"content","value":"\"Printed Document\"","position":{"start":{"line":183,"column":7},"end":{"line":183,"column":34},"source":"input.css"}}],"position":{"start":{"line":182,"column":5},"end":{"line":184,"column":6},"source":"input.css"}},{"type":"page-margin-box","name":"bottom-center","declarations":[{"type":"declaration","property":"content","value":"counter(page) \" / \" counter(pages)","position":{"start":{"line":187,"column":7},"end":{"line":187,"column":50},"source":"input.css"}}],"position":{"start":{"line":186,"column":5},"end":{"line":188,"column":6},"source":"input.css"}}],"position":{"start":{"line":179,"column":3},"end":{"line":189,"column":4},"source":"input.css"}},{"type":"rule","selectors":[".dashboard"],"declarations":[{"type":"declaration","property":"display","value":"block","position":{"start":{"line":192,"column":5},"end":{"line":192,"column":19},"source":"input.css"}},{"type":"rule","selectors":[".sidebar"],"declarations":[{"type":"declaration","property":"display","value":"none","position":{"start":{"line":195,"column":7},"end":{"line":195,"column":20},"source":"input.css"}}],"position":{"start":{"line":194,"column":5},"end":{"line":196,"column":6},"source":"input.css"}},{"type":"rule","selectors":[".main-content"],"declarations":[{"type":"declaration","property":"width","value":"100%","position":{"start":{"line":199,"column":7},"end":{"line":199,"column":18},"source":"input.css"}},{"type":"rule","selectors":[".card"],"declarations":[{"type":"declaration","property":"break-inside","value":"avoid","position":{"start":{"line":202,"column":9},"end":{"line":202,"column":28},"source":"input.css"}},{"type":"declaration","property":"page-break-inside","value":"avoid","position":{"start":{"line":203,"column":9},"end":{"line":203,"column":33},"source":"input.css"}}],"position":{"start":{"line":201,"column":7},"end":{"line":204,"column":8},"source":"input.css"}}],"position":{"start":{"line":198,"column":5},"end":{"line":205,"column":6},"source":"input.css"}}],"position":{"start":{"line":191,"column":3},"end":{"line":206,"column":4},"source":"input.css"}}],"position":{"start":{"line":178,"column":1},"end":{"line":207,"column":2},"source":"input.css"}}],"parsingErrors":[]}} \ No newline at end of file diff --git a/test/cases/complex-nesting/compressed.css b/test/cases/complex-nesting/compressed.css new file mode 100644 index 00000000..edff760e --- /dev/null +++ b/test/cases/complex-nesting/compressed.css @@ -0,0 +1 @@ +.dashboard{display:grid;gap:1rem;.sidebar{width:250px;nav{padding:1rem;a{color:inherit;text-decoration:none;&:hover{text-decoration:underline;}&::after{content:" \2192";}&.active{font-weight:bold;&::before{content:"\25B6";margin-right:0.5em;}}}ul{list-style:none;padding:0;> li{margin-bottom:0.5rem;+ li{border-top:1px solid #eee;padding-top:0.5rem;}}}}}.main-content{flex:1;@media (min-width: 768px){padding:2rem;}@media (min-width: 1024px){padding:3rem;.hero{font-size:2rem;}}@supports (container-type: inline-size){container-type:inline-size;@container (min-width: 500px){.card-grid{grid-template-columns:repeat(2, 1fr);}}}h1{font-size:1.5rem;margin-bottom:1rem;~ p{color:#666;}}.card{border:1px solid #ddd;border-radius:8px;padding:1rem;&:first-child{border-color:blue;}&:not(:last-child){margin-bottom:1rem;}.card-header{font-weight:bold;.card-title{font-size:1.2em;}.card-subtitle{color:#999;font-size:0.9em;}}.card-body{margin-top:0.5rem;}.card-footer{margin-top:1rem;border-top:1px solid #eee;padding-top:0.5rem;button{cursor:pointer;&:disabled{opacity:0.5;cursor:not-allowed;}}}}}}@layer base{@layer reset{*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}}@layer typography{body{font-family:system-ui, sans-serif;line-height:1.5;@media (prefers-color-scheme: dark){color:#f0f0f0;background:#1a1a1a;}}}}@scope (.theme-dark) to (.theme-light){:scope{color:white;background:#333;a{color:lightblue;&:visited{color:plum;}}}.card{background:#444;border-color:#555;@media (prefers-contrast: high){border-width:2px;border-color:white;}}}@media print{@page {margin:2cm;@top-center{content:"Printed Document";}@bottom-center{content:counter(page) " / " counter(pages);}}.dashboard{display:block;.sidebar{display:none;}.main-content{width:100%;.card{break-inside:avoid;page-break-inside:avoid;}}}} \ No newline at end of file diff --git a/test/cases/complex-nesting/input.css b/test/cases/complex-nesting/input.css new file mode 100644 index 00000000..62d12338 --- /dev/null +++ b/test/cases/complex-nesting/input.css @@ -0,0 +1,207 @@ +.dashboard { + display: grid; + gap: 1rem; + + .sidebar { + width: 250px; + + nav { + padding: 1rem; + + a { + color: inherit; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + &::after { + content: " \2192"; + } + + &.active { + font-weight: bold; + + &::before { + content: "\25B6"; + margin-right: 0.5em; + } + } + } + + ul { + list-style: none; + padding: 0; + + > li { + margin-bottom: 0.5rem; + + + li { + border-top: 1px solid #eee; + padding-top: 0.5rem; + } + } + } + } + } + + .main-content { + flex: 1; + + @media (min-width: 768px) { + padding: 2rem; + } + + @media (min-width: 1024px) { + padding: 3rem; + + .hero { + font-size: 2rem; + } + } + + @supports (container-type: inline-size) { + container-type: inline-size; + + @container (min-width: 500px) { + .card-grid { + grid-template-columns: repeat(2, 1fr); + } + } + } + + h1 { + font-size: 1.5rem; + margin-bottom: 1rem; + + ~ p { + color: #666; + } + } + + .card { + border: 1px solid #ddd; + border-radius: 8px; + padding: 1rem; + + &:first-child { + border-color: blue; + } + + &:not(:last-child) { + margin-bottom: 1rem; + } + + .card-header { + font-weight: bold; + + .card-title { + font-size: 1.2em; + } + + .card-subtitle { + color: #999; + font-size: 0.9em; + } + } + + .card-body { + margin-top: 0.5rem; + } + + .card-footer { + margin-top: 1rem; + border-top: 1px solid #eee; + padding-top: 0.5rem; + + button { + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + } + } + } +} + +@layer base { + @layer reset { + *, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + } + } + + @layer typography { + body { + font-family: system-ui, sans-serif; + line-height: 1.5; + + @media (prefers-color-scheme: dark) { + color: #f0f0f0; + background: #1a1a1a; + } + } + } +} + +@scope (.theme-dark) to (.theme-light) { + :scope { + color: white; + background: #333; + + a { + color: lightblue; + + &:visited { + color: plum; + } + } + } + + .card { + background: #444; + border-color: #555; + + @media (prefers-contrast: high) { + border-width: 2px; + border-color: white; + } + } +} + +@media print { + @page { + margin: 2cm; + + @top-center { + content: "Printed Document"; + } + + @bottom-center { + content: counter(page) " / " counter(pages); + } + } + + .dashboard { + display: block; + + .sidebar { + display: none; + } + + .main-content { + width: 100%; + + .card { + break-inside: avoid; + page-break-inside: avoid; + } + } + } +} diff --git a/test/cases/complex-nesting/output.css b/test/cases/complex-nesting/output.css new file mode 100644 index 00000000..9b964c49 --- /dev/null +++ b/test/cases/complex-nesting/output.css @@ -0,0 +1,176 @@ +.dashboard { + display: grid; + gap: 1rem; + .sidebar { + width: 250px; + nav { + padding: 1rem; + a { + color: inherit; + text-decoration: none; + &:hover { + text-decoration: underline; + } + &::after { + content: " \2192"; + } + &.active { + font-weight: bold; + &::before { + content: "\25B6"; + margin-right: 0.5em; + } + } + } + ul { + list-style: none; + padding: 0; + > li { + margin-bottom: 0.5rem; + + li { + border-top: 1px solid #eee; + padding-top: 0.5rem; + } + } + } + } + } + .main-content { + flex: 1; + @media (min-width: 768px) { + padding: 2rem; + } + @media (min-width: 1024px) { + padding: 3rem; + + .hero { + font-size: 2rem; + } + } + @supports (container-type: inline-size) { + container-type: inline-size; + + @container (min-width: 500px) { + .card-grid { + grid-template-columns: repeat(2, 1fr); + } + } + } + h1 { + font-size: 1.5rem; + margin-bottom: 1rem; + ~ p { + color: #666; + } + } + .card { + border: 1px solid #ddd; + border-radius: 8px; + padding: 1rem; + &:first-child { + border-color: blue; + } + &:not(:last-child) { + margin-bottom: 1rem; + } + .card-header { + font-weight: bold; + .card-title { + font-size: 1.2em; + } + .card-subtitle { + color: #999; + font-size: 0.9em; + } + } + .card-body { + margin-top: 0.5rem; + } + .card-footer { + margin-top: 1rem; + border-top: 1px solid #eee; + padding-top: 0.5rem; + button { + cursor: pointer; + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + } + } + } +} + +@layer base { + @layer reset { + *, + *::before, + *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + } + } + + @layer typography { + body { + font-family: system-ui, sans-serif; + line-height: 1.5; + @media (prefers-color-scheme: dark) { + color: #f0f0f0; + + background: #1a1a1a; + } + } + } +} + +@scope (.theme-dark) to (.theme-light) { + :scope { + color: white; + background: #333; + a { + color: lightblue; + &:visited { + color: plum; + } + } + } + + .card { + background: #444; + border-color: #555; + @media (prefers-contrast: high) { + border-width: 2px; + + border-color: white; + } + } +} + +@media print { +@page { + margin: 2cm; + @top-center { + content: "Printed Document"; + } + @bottom-center { + content: counter(page) " / " counter(pages); + } +} + + .dashboard { + display: block; + .sidebar { + display: none; + } + .main-content { + width: 100%; + .card { + break-inside: avoid; + page-break-inside: avoid; + } + } + } +} \ No newline at end of file diff --git a/test/cases/container-queries/ast.json b/test/cases/container-queries/ast.json new file mode 100644 index 00000000..c806e3b5 --- /dev/null +++ b/test/cases/container-queries/ast.json @@ -0,0 +1 @@ +{"type":"stylesheet","stylesheet":{"source":"input.css","rules":[{"type":"rule","selectors":[".widget-wrapper"],"declarations":[{"type":"declaration","property":"container-type","value":"inline-size","position":{"start":{"line":2,"column":3},"end":{"line":2,"column":30},"source":"input.css"}},{"type":"declaration","property":"container-name","value":"widget","position":{"start":{"line":3,"column":3},"end":{"line":3,"column":25},"source":"input.css"}}],"position":{"start":{"line":1,"column":1},"end":{"line":4,"column":2},"source":"input.css"}},{"type":"container","container":"widget (min-width: 300px)","rules":[{"type":"rule","selectors":[".widget"],"declarations":[{"type":"declaration","property":"display","value":"flex","position":{"start":{"line":8,"column":5},"end":{"line":8,"column":18},"source":"input.css"}},{"type":"declaration","property":"gap","value":"1rem","position":{"start":{"line":9,"column":5},"end":{"line":9,"column":14},"source":"input.css"}}],"position":{"start":{"line":7,"column":3},"end":{"line":10,"column":4},"source":"input.css"}}],"position":{"start":{"line":6,"column":1},"end":{"line":11,"column":2},"source":"input.css"}},{"type":"container","container":"widget (min-width: 500px)","rules":[{"type":"rule","selectors":[".widget"],"declarations":[{"type":"declaration","property":"flex-direction","value":"row","position":{"start":{"line":15,"column":5},"end":{"line":15,"column":24},"source":"input.css"}},{"type":"rule","selectors":[".widget-image"],"declarations":[{"type":"declaration","property":"width","value":"200px","position":{"start":{"line":18,"column":7},"end":{"line":18,"column":19},"source":"input.css"}}],"position":{"start":{"line":17,"column":5},"end":{"line":19,"column":6},"source":"input.css"}},{"type":"rule","selectors":[".widget-content"],"declarations":[{"type":"declaration","property":"flex","value":"1","position":{"start":{"line":22,"column":7},"end":{"line":22,"column":14},"source":"input.css"}},{"type":"rule","selectors":["h2"],"declarations":[{"type":"declaration","property":"font-size","value":"1.5rem","position":{"start":{"line":25,"column":9},"end":{"line":25,"column":26},"source":"input.css"}}],"position":{"start":{"line":24,"column":7},"end":{"line":26,"column":8},"source":"input.css"}},{"type":"rule","selectors":["p"],"declarations":[{"type":"declaration","property":"line-height","value":"1.6","position":{"start":{"line":29,"column":9},"end":{"line":29,"column":25},"source":"input.css"}}],"position":{"start":{"line":28,"column":7},"end":{"line":30,"column":8},"source":"input.css"}}],"position":{"start":{"line":21,"column":5},"end":{"line":31,"column":6},"source":"input.css"}}],"position":{"start":{"line":14,"column":3},"end":{"line":32,"column":4},"source":"input.css"}}],"position":{"start":{"line":13,"column":1},"end":{"line":33,"column":2},"source":"input.css"}},{"type":"container","container":"widget (min-width: 700px) and (min-height: 400px)","rules":[{"type":"rule","selectors":[".widget"],"declarations":[{"type":"declaration","property":"padding","value":"2rem","position":{"start":{"line":37,"column":5},"end":{"line":37,"column":18},"source":"input.css"}},{"type":"rule","selectors":[".widget-image"],"declarations":[{"type":"declaration","property":"width","value":"300px","position":{"start":{"line":40,"column":7},"end":{"line":40,"column":19},"source":"input.css"}},{"type":"declaration","property":"aspect-ratio","value":"16 / 9","position":{"start":{"line":41,"column":7},"end":{"line":41,"column":27},"source":"input.css"}}],"position":{"start":{"line":39,"column":5},"end":{"line":42,"column":6},"source":"input.css"}}],"position":{"start":{"line":36,"column":3},"end":{"line":43,"column":4},"source":"input.css"}}],"position":{"start":{"line":35,"column":1},"end":{"line":44,"column":2},"source":"input.css"}},{"type":"rule","selectors":[".responsive-grid"],"declarations":[{"type":"declaration","property":"container-type","value":"inline-size","position":{"start":{"line":47,"column":3},"end":{"line":47,"column":30},"source":"input.css"}},{"type":"declaration","property":"container-name","value":"grid-container","position":{"start":{"line":48,"column":3},"end":{"line":48,"column":33},"source":"input.css"}},{"type":"declaration","property":"display","value":"grid","position":{"start":{"line":49,"column":3},"end":{"line":49,"column":16},"source":"input.css"}},{"type":"declaration","property":"gap","value":"1rem","position":{"start":{"line":50,"column":3},"end":{"line":50,"column":12},"source":"input.css"}},{"type":"rule","selectors":[".grid-item"],"declarations":[{"type":"declaration","property":"padding","value":"1rem","position":{"start":{"line":53,"column":5},"end":{"line":53,"column":18},"source":"input.css"}},{"type":"declaration","property":"border","value":"1px solid #ccc","position":{"start":{"line":54,"column":5},"end":{"line":54,"column":27},"source":"input.css"}},{"type":"container","container":"grid-container (min-width: 400px)","rules":[{"type":"declaration","property":"padding","value":"1.5rem","position":{"start":{"line":57,"column":7},"end":{"line":57,"column":22},"source":"input.css"}}],"position":{"start":{"line":56,"column":5},"end":{"line":58,"column":6},"source":"input.css"}},{"type":"container","container":"grid-container (min-width: 800px)","rules":[{"type":"declaration","property":"padding","value":"2rem","position":{"start":{"line":61,"column":7},"end":{"line":61,"column":20},"source":"input.css"}},{"type":"rule","selectors":[".item-title"],"declarations":[{"type":"declaration","property":"font-size","value":"1.3rem","position":{"start":{"line":64,"column":9},"end":{"line":64,"column":26},"source":"input.css"}}],"position":{"start":{"line":63,"column":7},"end":{"line":65,"column":8},"source":"input.css"}}],"position":{"start":{"line":60,"column":5},"end":{"line":66,"column":6},"source":"input.css"}}],"position":{"start":{"line":52,"column":3},"end":{"line":67,"column":4},"source":"input.css"}}],"position":{"start":{"line":46,"column":1},"end":{"line":68,"column":2},"source":"input.css"}},{"type":"container","container":"(min-width: 0)","rules":[{"type":"rule","selectors":[".always-flex"],"declarations":[{"type":"declaration","property":"display","value":"flex","position":{"start":{"line":72,"column":5},"end":{"line":72,"column":18},"source":"input.css"}}],"position":{"start":{"line":71,"column":3},"end":{"line":73,"column":4},"source":"input.css"}}],"position":{"start":{"line":70,"column":1},"end":{"line":74,"column":2},"source":"input.css"}},{"type":"container","container":"style(--theme: dark)","rules":[{"type":"rule","selectors":[".themed"],"declarations":[{"type":"declaration","property":"background","value":"#222","position":{"start":{"line":78,"column":5},"end":{"line":78,"column":21},"source":"input.css"}},{"type":"declaration","property":"color","value":"#eee","position":{"start":{"line":79,"column":5},"end":{"line":79,"column":16},"source":"input.css"}}],"position":{"start":{"line":77,"column":3},"end":{"line":80,"column":4},"source":"input.css"}}],"position":{"start":{"line":76,"column":1},"end":{"line":81,"column":2},"source":"input.css"}},{"type":"container","container":"widget (width > 500px)","rules":[{"type":"rule","selectors":[".modern-syntax"],"declarations":[{"type":"declaration","property":"font-size","value":"1.2rem","position":{"start":{"line":85,"column":5},"end":{"line":85,"column":22},"source":"input.css"}}],"position":{"start":{"line":84,"column":3},"end":{"line":86,"column":4},"source":"input.css"}}],"position":{"start":{"line":83,"column":1},"end":{"line":87,"column":2},"source":"input.css"}},{"type":"rule","selectors":[".multi-container"],"declarations":[{"type":"declaration","property":"container","value":"sidebar / inline-size","position":{"start":{"line":90,"column":3},"end":{"line":90,"column":35},"source":"input.css"}}],"position":{"start":{"line":89,"column":1},"end":{"line":91,"column":2},"source":"input.css"}},{"type":"container","container":"sidebar (min-width: 200px)","rules":[{"type":"rule","selectors":[".sidebar-content"],"declarations":[{"type":"declaration","property":"display","value":"block","position":{"start":{"line":95,"column":5},"end":{"line":95,"column":19},"source":"input.css"}},{"type":"container","container":"sidebar (min-width: 400px)","rules":[{"type":"declaration","property":"display","value":"grid","position":{"start":{"line":98,"column":7},"end":{"line":98,"column":20},"source":"input.css"}},{"type":"declaration","property":"grid-template-columns","value":"1fr 1fr","position":{"start":{"line":99,"column":7},"end":{"line":99,"column":37},"source":"input.css"}}],"position":{"start":{"line":97,"column":5},"end":{"line":100,"column":6},"source":"input.css"}}],"position":{"start":{"line":94,"column":3},"end":{"line":101,"column":4},"source":"input.css"}}],"position":{"start":{"line":93,"column":1},"end":{"line":102,"column":2},"source":"input.css"}}],"parsingErrors":[]}} \ No newline at end of file diff --git a/test/cases/container-queries/compressed.css b/test/cases/container-queries/compressed.css new file mode 100644 index 00000000..7bf7899e --- /dev/null +++ b/test/cases/container-queries/compressed.css @@ -0,0 +1 @@ +.widget-wrapper{container-type:inline-size;container-name:widget;}@container widget (min-width: 300px){.widget{display:flex;gap:1rem;}}@container widget (min-width: 500px){.widget{flex-direction:row;.widget-image{width:200px;}.widget-content{flex:1;h2{font-size:1.5rem;}p{line-height:1.6;}}}}@container widget (min-width: 700px) and (min-height: 400px){.widget{padding:2rem;.widget-image{width:300px;aspect-ratio:16 / 9;}}}.responsive-grid{container-type:inline-size;container-name:grid-container;display:grid;gap:1rem;.grid-item{padding:1rem;border:1px solid #ccc;@container grid-container (min-width: 400px){padding:1.5rem;}@container grid-container (min-width: 800px){padding:2rem;.item-title{font-size:1.3rem;}}}}@container (min-width: 0){.always-flex{display:flex;}}@container style(--theme: dark){.themed{background:#222;color:#eee;}}@container widget (width > 500px){.modern-syntax{font-size:1.2rem;}}.multi-container{container:sidebar / inline-size;}@container sidebar (min-width: 200px){.sidebar-content{display:block;@container sidebar (min-width: 400px){display:grid;grid-template-columns:1fr 1fr;}}} \ No newline at end of file diff --git a/test/cases/container-queries/input.css b/test/cases/container-queries/input.css new file mode 100644 index 00000000..c7d17cd6 --- /dev/null +++ b/test/cases/container-queries/input.css @@ -0,0 +1,102 @@ +.widget-wrapper { + container-type: inline-size; + container-name: widget; +} + +@container widget (min-width: 300px) { + .widget { + display: flex; + gap: 1rem; + } +} + +@container widget (min-width: 500px) { + .widget { + flex-direction: row; + + .widget-image { + width: 200px; + } + + .widget-content { + flex: 1; + + h2 { + font-size: 1.5rem; + } + + p { + line-height: 1.6; + } + } + } +} + +@container widget (min-width: 700px) and (min-height: 400px) { + .widget { + padding: 2rem; + + .widget-image { + width: 300px; + aspect-ratio: 16 / 9; + } + } +} + +.responsive-grid { + container-type: inline-size; + container-name: grid-container; + display: grid; + gap: 1rem; + + .grid-item { + padding: 1rem; + border: 1px solid #ccc; + + @container grid-container (min-width: 400px) { + padding: 1.5rem; + } + + @container grid-container (min-width: 800px) { + padding: 2rem; + + .item-title { + font-size: 1.3rem; + } + } + } +} + +@container (min-width: 0) { + .always-flex { + display: flex; + } +} + +@container style(--theme: dark) { + .themed { + background: #222; + color: #eee; + } +} + +@container widget (width > 500px) { + .modern-syntax { + font-size: 1.2rem; + } +} + +.multi-container { + container: sidebar / inline-size; +} + +@container sidebar (min-width: 200px) { + .sidebar-content { + display: block; + + @container sidebar (min-width: 400px) { + display: grid; + grid-template-columns: 1fr 1fr; + } + } +} diff --git a/test/cases/container-queries/output.css b/test/cases/container-queries/output.css new file mode 100644 index 00000000..be0c83df --- /dev/null +++ b/test/cases/container-queries/output.css @@ -0,0 +1,94 @@ +.widget-wrapper { + container-type: inline-size; + container-name: widget; +} + +@container widget (min-width: 300px) { + .widget { + display: flex; + gap: 1rem; + } +} + +@container widget (min-width: 500px) { + .widget { + flex-direction: row; + .widget-image { + width: 200px; + } + .widget-content { + flex: 1; + h2 { + font-size: 1.5rem; + } + p { + line-height: 1.6; + } + } + } +} + +@container widget (min-width: 700px) and (min-height: 400px) { + .widget { + padding: 2rem; + .widget-image { + width: 300px; + aspect-ratio: 16 / 9; + } + } +} + +.responsive-grid { + container-type: inline-size; + container-name: grid-container; + display: grid; + gap: 1rem; + .grid-item { + padding: 1rem; + border: 1px solid #ccc; + @container grid-container (min-width: 400px) { + padding: 1.5rem; + } + @container grid-container (min-width: 800px) { + padding: 2rem; + + .item-title { + font-size: 1.3rem; + } + } + } +} + +@container (min-width: 0) { + .always-flex { + display: flex; + } +} + +@container style(--theme: dark) { + .themed { + background: #222; + color: #eee; + } +} + +@container widget (width > 500px) { + .modern-syntax { + font-size: 1.2rem; + } +} + +.multi-container { + container: sidebar / inline-size; +} + +@container sidebar (min-width: 200px) { + .sidebar-content { + display: block; + @container sidebar (min-width: 400px) { + display: grid; + + grid-template-columns: 1fr 1fr; + } + } +} \ No newline at end of file diff --git a/test/cases/special-strings/ast.json b/test/cases/special-strings/ast.json new file mode 100644 index 00000000..7bc080d7 --- /dev/null +++ b/test/cases/special-strings/ast.json @@ -0,0 +1 @@ +{"type":"stylesheet","stylesheet":{"source":"input.css","rules":[{"type":"rule","selectors":[".escaped-content"],"declarations":[{"type":"declaration","property":"content","value":"\"braces { } and semicolons ; in strings\"","position":{"start":{"line":2,"column":3},"end":{"line":2,"column":52},"source":"input.css"}},{"type":"declaration","property":"--data","value":"\"nested \\\"quotes\\\" inside\"","position":{"start":{"line":3,"column":3},"end":{"line":3,"column":37},"source":"input.css"}},{"type":"declaration","property":"background","value":"url(\"path/to/file;with;semis.png\")","position":{"start":{"line":4,"column":3},"end":{"line":4,"column":49},"source":"input.css"}},{"type":"declaration","property":"font-family","value":"\"Font; Name {Special}\", sans-serif","position":{"start":{"line":5,"column":3},"end":{"line":5,"column":50},"source":"input.css"}}],"position":{"start":{"line":1,"column":1},"end":{"line":6,"column":2},"source":"input.css"}},{"type":"rule","selectors":[".single-quoted"],"declarations":[{"type":"declaration","property":"content","value":"'single with } and ; chars'","position":{"start":{"line":9,"column":3},"end":{"line":9,"column":39},"source":"input.css"}},{"type":"declaration","property":"background","value":"url('image;{name}.jpg')","position":{"start":{"line":10,"column":3},"end":{"line":10,"column":38},"source":"input.css"}},{"type":"declaration","property":"--value","value":"'it\\'s escaped'","position":{"start":{"line":11,"column":3},"end":{"line":11,"column":27},"source":"input.css"}}],"position":{"start":{"line":8,"column":1},"end":{"line":12,"column":2},"source":"input.css"}},{"type":"rule","selectors":[".url-values"],"declarations":[{"type":"declaration","property":"background","value":"url(unquoted/path/image.png)","position":{"start":{"line":15,"column":3},"end":{"line":15,"column":43},"source":"input.css"}},{"type":"declaration","property":"background","value":"url(\"quoted/path;special.png\")","position":{"start":{"line":16,"column":3},"end":{"line":16,"column":45},"source":"input.css"}},{"type":"declaration","property":"background","value":"url('single/quoted;path.png')","position":{"start":{"line":17,"column":3},"end":{"line":17,"column":44},"source":"input.css"}},{"type":"declaration","property":"cursor","value":"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3C/svg%3E\"), auto","position":{"start":{"line":18,"column":3},"end":{"line":18,"column":97},"source":"input.css"}}],"position":{"start":{"line":14,"column":1},"end":{"line":19,"column":2},"source":"input.css"}},{"type":"rule","selectors":[".calc-values"],"declarations":[{"type":"declaration","property":"width","value":"calc(100% - 2rem)","position":{"start":{"line":22,"column":3},"end":{"line":22,"column":27},"source":"input.css"}},{"type":"declaration","property":"height","value":"calc(100vh - calc(3rem + 10px))","position":{"start":{"line":23,"column":3},"end":{"line":23,"column":42},"source":"input.css"}},{"type":"declaration","property":"margin","value":"calc((100% - 960px) / 2)","position":{"start":{"line":24,"column":3},"end":{"line":24,"column":35},"source":"input.css"}},{"type":"declaration","property":"padding","value":"clamp(1rem, 2vw + 0.5rem, 3rem)","position":{"start":{"line":25,"column":3},"end":{"line":25,"column":43},"source":"input.css"}},{"type":"declaration","property":"font-size","value":"min(max(1rem, 4vw), 2rem)","position":{"start":{"line":26,"column":3},"end":{"line":26,"column":39},"source":"input.css"}}],"position":{"start":{"line":21,"column":1},"end":{"line":27,"column":2},"source":"input.css"}},{"type":"rule","selectors":[".grid-areas"],"declarations":[{"type":"declaration","property":"grid-template-areas","value":"\"header header header\"\n \"sidebar main aside\"\n \"footer footer footer\"","position":{"start":{"line":30,"column":3},"end":{"line":33,"column":27},"source":"input.css"}},{"type":"declaration","property":"grid-template-columns","value":"200px 1fr 150px","position":{"start":{"line":34,"column":3},"end":{"line":34,"column":41},"source":"input.css"}}],"position":{"start":{"line":29,"column":1},"end":{"line":35,"column":2},"source":"input.css"}},{"type":"rule","selectors":[".pseudo-content"],"declarations":[{"type":"declaration","property":"content","value":"\"\\201C\" attr(data-quote) \"\\201D\"","position":{"start":{"line":38,"column":3},"end":{"line":38,"column":44},"source":"input.css"}}],"position":{"start":{"line":37,"column":1},"end":{"line":39,"column":2},"source":"input.css"}},{"type":"rule","selectors":[".complex-selectors"],"declarations":[{"type":"declaration","property":"background","value":"rgb(255, 128, 0)","position":{"start":{"line":42,"column":3},"end":{"line":42,"column":31},"source":"input.css"}},{"type":"rule","selectors":["[data-value=\"with;semicolon\"]"],"declarations":[{"type":"declaration","property":"color","value":"red","position":{"start":{"line":45,"column":5},"end":{"line":45,"column":15},"source":"input.css"}}],"position":{"start":{"line":44,"column":3},"end":{"line":46,"column":4},"source":"input.css"}},{"type":"rule","selectors":["[data-attr=\"braces{}here\"]"],"declarations":[{"type":"declaration","property":"color","value":"blue","position":{"start":{"line":49,"column":5},"end":{"line":49,"column":16},"source":"input.css"}}],"position":{"start":{"line":48,"column":3},"end":{"line":50,"column":4},"source":"input.css"}},{"type":"rule","selectors":["&:is(.a, .b, .c)"],"declarations":[{"type":"declaration","property":"font-weight","value":"bold","position":{"start":{"line":53,"column":5},"end":{"line":53,"column":22},"source":"input.css"}}],"position":{"start":{"line":52,"column":3},"end":{"line":54,"column":4},"source":"input.css"}},{"type":"rule","selectors":["&:where(:not(:first-child):not(:last-child))"],"declarations":[{"type":"declaration","property":"margin","value":"0 0.5rem","position":{"start":{"line":57,"column":5},"end":{"line":57,"column":21},"source":"input.css"}}],"position":{"start":{"line":56,"column":3},"end":{"line":58,"column":4},"source":"input.css"}},{"type":"rule","selectors":["&:has(> .icon)"],"declarations":[{"type":"declaration","property":"padding-left","value":"2rem","position":{"start":{"line":61,"column":5},"end":{"line":61,"column":23},"source":"input.css"}}],"position":{"start":{"line":60,"column":3},"end":{"line":62,"column":4},"source":"input.css"}}],"position":{"start":{"line":41,"column":1},"end":{"line":63,"column":2},"source":"input.css"}},{"type":"rule","selectors":[".var-values"],"declarations":[{"type":"declaration","property":"color","value":"var(--primary-color, rgb(0, 100, 200))","position":{"start":{"line":66,"column":3},"end":{"line":66,"column":48},"source":"input.css"}},{"type":"declaration","property":"background","value":"var(--bg, var(--fallback-bg, #fff))","position":{"start":{"line":67,"column":3},"end":{"line":67,"column":50},"source":"input.css"}},{"type":"declaration","property":"border","value":"var(--border-width, 1px) solid var(--border-color, #ccc)","position":{"start":{"line":68,"column":3},"end":{"line":68,"column":67},"source":"input.css"}},{"type":"declaration","property":"font","value":"var(--font-weight, 400) var(--font-size, 1rem) / var(--line-height, 1.5) var(--font-family, sans-serif)","position":{"start":{"line":69,"column":3},"end":{"line":69,"column":112},"source":"input.css"}}],"position":{"start":{"line":65,"column":1},"end":{"line":70,"column":2},"source":"input.css"}},{"type":"rule","selectors":[".comma-lists"],"declarations":[{"type":"declaration","property":"transition","value":"color 0.3s ease, background-color 0.3s ease, transform 0.2s","position":{"start":{"line":73,"column":3},"end":{"line":73,"column":74},"source":"input.css"}},{"type":"declaration","property":"background","value":"linear-gradient(to right, red, orange, yellow, green, blue)","position":{"start":{"line":74,"column":3},"end":{"line":74,"column":74},"source":"input.css"}},{"type":"declaration","property":"box-shadow","value":"0 2px 4px rgba(0, 0, 0, 0.1), 0 8px 16px rgba(0, 0, 0, 0.1)","position":{"start":{"line":75,"column":3},"end":{"line":75,"column":74},"source":"input.css"}},{"type":"declaration","property":"font-family","value":"\"Helvetica Neue\", Helvetica, Arial, \"Liberation Sans\", sans-serif","position":{"start":{"line":76,"column":3},"end":{"line":76,"column":81},"source":"input.css"}}],"position":{"start":{"line":72,"column":1},"end":{"line":77,"column":2},"source":"input.css"}},{"type":"property","name":"--gradient-angle","declarations":[{"type":"declaration","property":"syntax","value":"\"\"","position":{"start":{"line":80,"column":3},"end":{"line":80,"column":20},"source":"input.css"}},{"type":"declaration","property":"inherits","value":"false","position":{"start":{"line":81,"column":3},"end":{"line":81,"column":18},"source":"input.css"}},{"type":"declaration","property":"initial-value","value":"0deg","position":{"start":{"line":82,"column":3},"end":{"line":82,"column":22},"source":"input.css"}}],"position":{"start":{"line":79,"column":1},"end":{"line":83,"column":2},"source":"input.css"}},{"type":"rule","selectors":[".animated-gradient"],"declarations":[{"type":"declaration","property":"--gradient-angle","value":"0deg","position":{"start":{"line":86,"column":3},"end":{"line":86,"column":25},"source":"input.css"}},{"type":"declaration","property":"background","value":"conic-gradient(from var(--gradient-angle), red, blue, red)","position":{"start":{"line":87,"column":3},"end":{"line":87,"column":73},"source":"input.css"}}],"position":{"start":{"line":85,"column":1},"end":{"line":88,"column":2},"source":"input.css"}}],"parsingErrors":[]}} \ No newline at end of file diff --git a/test/cases/special-strings/compressed.css b/test/cases/special-strings/compressed.css new file mode 100644 index 00000000..b5446cb7 --- /dev/null +++ b/test/cases/special-strings/compressed.css @@ -0,0 +1,3 @@ +.escaped-content{content:"braces { } and semicolons ; in strings";--data:"nested \"quotes\" inside";background:url("path/to/file;with;semis.png");font-family:"Font; Name {Special}", sans-serif;}.single-quoted{content:'single with } and ; chars';background:url('image;{name}.jpg');--value:'it\'s escaped';}.url-values{background:url(unquoted/path/image.png);background:url("quoted/path;special.png");background:url('single/quoted;path.png');cursor:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3C/svg%3E"), auto;}.calc-values{width:calc(100% - 2rem);height:calc(100vh - calc(3rem + 10px));margin:calc((100% - 960px) / 2);padding:clamp(1rem, 2vw + 0.5rem, 3rem);font-size:min(max(1rem, 4vw), 2rem);}.grid-areas{grid-template-areas:"header header header" + "sidebar main aside" + "footer footer footer";grid-template-columns:200px 1fr 150px;}.pseudo-content{content:"\201C" attr(data-quote) "\201D";}.complex-selectors{background:rgb(255, 128, 0);[data-value="with;semicolon"]{color:red;}[data-attr="braces{}here"]{color:blue;}&:is(.a, .b, .c){font-weight:bold;}&:where(:not(:first-child):not(:last-child)){margin:0 0.5rem;}&:has(> .icon){padding-left:2rem;}}.var-values{color:var(--primary-color, rgb(0, 100, 200));background:var(--bg, var(--fallback-bg, #fff));border:var(--border-width, 1px) solid var(--border-color, #ccc);font:var(--font-weight, 400) var(--font-size, 1rem) / var(--line-height, 1.5) var(--font-family, sans-serif);}.comma-lists{transition:color 0.3s ease, background-color 0.3s ease, transform 0.2s;background:linear-gradient(to right, red, orange, yellow, green, blue);box-shadow:0 2px 4px rgba(0, 0, 0, 0.1), 0 8px 16px rgba(0, 0, 0, 0.1);font-family:"Helvetica Neue", Helvetica, Arial, "Liberation Sans", sans-serif;}@property --gradient-angle{syntax:"";inherits:false;initial-value:0deg;}.animated-gradient{--gradient-angle:0deg;background:conic-gradient(from var(--gradient-angle), red, blue, red);} \ No newline at end of file diff --git a/test/cases/special-strings/input.css b/test/cases/special-strings/input.css new file mode 100644 index 00000000..3b221afd --- /dev/null +++ b/test/cases/special-strings/input.css @@ -0,0 +1,88 @@ +.escaped-content { + content: "braces { } and semicolons ; in strings"; + --data: "nested \"quotes\" inside"; + background: url("path/to/file;with;semis.png"); + font-family: "Font; Name {Special}", sans-serif; +} + +.single-quoted { + content: 'single with } and ; chars'; + background: url('image;{name}.jpg'); + --value: 'it\'s escaped'; +} + +.url-values { + background: url(unquoted/path/image.png); + background: url("quoted/path;special.png"); + background: url('single/quoted;path.png'); + cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3C/svg%3E"), auto; +} + +.calc-values { + width: calc(100% - 2rem); + height: calc(100vh - calc(3rem + 10px)); + margin: calc((100% - 960px) / 2); + padding: clamp(1rem, 2vw + 0.5rem, 3rem); + font-size: min(max(1rem, 4vw), 2rem); +} + +.grid-areas { + grid-template-areas: + "header header header" + "sidebar main aside" + "footer footer footer"; + grid-template-columns: 200px 1fr 150px; +} + +.pseudo-content { + content: "\201C" attr(data-quote) "\201D"; +} + +.complex-selectors { + background: rgb(255, 128, 0); + + [data-value="with;semicolon"] { + color: red; + } + + [data-attr="braces{}here"] { + color: blue; + } + + &:is(.a, .b, .c) { + font-weight: bold; + } + + &:where(:not(:first-child):not(:last-child)) { + margin: 0 0.5rem; + } + + &:has(> .icon) { + padding-left: 2rem; + } +} + +.var-values { + color: var(--primary-color, rgb(0, 100, 200)); + background: var(--bg, var(--fallback-bg, #fff)); + border: var(--border-width, 1px) solid var(--border-color, #ccc); + font: var(--font-weight, 400) var(--font-size, 1rem) / var(--line-height, 1.5) var(--font-family, sans-serif); +} + +.comma-lists { + transition: color 0.3s ease, background-color 0.3s ease, transform 0.2s; + background: linear-gradient(to right, red, orange, yellow, green, blue); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), 0 8px 16px rgba(0, 0, 0, 0.1); + font-family: "Helvetica Neue", Helvetica, Arial, "Liberation Sans", sans-serif; +} + +@property --gradient-angle { + syntax: ""; + inherits: false; + initial-value: 0deg; +} + +.animated-gradient { + --gradient-angle: 0deg; + background: conic-gradient(from var(--gradient-angle), red, blue, red); +} diff --git a/test/cases/special-strings/output.css b/test/cases/special-strings/output.css new file mode 100644 index 00000000..067fb444 --- /dev/null +++ b/test/cases/special-strings/output.css @@ -0,0 +1,82 @@ +.escaped-content { + content: "braces { } and semicolons ; in strings"; + --data: "nested \"quotes\" inside"; + background: url("path/to/file;with;semis.png"); + font-family: "Font; Name {Special}", sans-serif; +} + +.single-quoted { + content: 'single with } and ; chars'; + background: url('image;{name}.jpg'); + --value: 'it\'s escaped'; +} + +.url-values { + background: url(unquoted/path/image.png); + background: url("quoted/path;special.png"); + background: url('single/quoted;path.png'); + cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3C/svg%3E"), auto; +} + +.calc-values { + width: calc(100% - 2rem); + height: calc(100vh - calc(3rem + 10px)); + margin: calc((100% - 960px) / 2); + padding: clamp(1rem, 2vw + 0.5rem, 3rem); + font-size: min(max(1rem, 4vw), 2rem); +} + +.grid-areas { + grid-template-areas: "header header header" + "sidebar main aside" + "footer footer footer"; + grid-template-columns: 200px 1fr 150px; +} + +.pseudo-content { + content: "\201C" attr(data-quote) "\201D"; +} + +.complex-selectors { + background: rgb(255, 128, 0); + [data-value="with;semicolon"] { + color: red; + } + [data-attr="braces{}here"] { + color: blue; + } + &:is(.a, .b, .c) { + font-weight: bold; + } + &:where(:not(:first-child):not(:last-child)) { + margin: 0 0.5rem; + } + &:has(> .icon) { + padding-left: 2rem; + } +} + +.var-values { + color: var(--primary-color, rgb(0, 100, 200)); + background: var(--bg, var(--fallback-bg, #fff)); + border: var(--border-width, 1px) solid var(--border-color, #ccc); + font: var(--font-weight, 400) var(--font-size, 1rem) / var(--line-height, 1.5) var(--font-family, sans-serif); +} + +.comma-lists { + transition: color 0.3s ease, background-color 0.3s ease, transform 0.2s; + background: linear-gradient(to right, red, orange, yellow, green, blue); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), 0 8px 16px rgba(0, 0, 0, 0.1); + font-family: "Helvetica Neue", Helvetica, Arial, "Liberation Sans", sans-serif; +} + +@property --gradient-angle { + syntax: ""; + inherits: false; + initial-value: 0deg; +} + +.animated-gradient { + --gradient-angle: 0deg; + background: conic-gradient(from var(--gradient-angle), red, blue, red); +} \ No newline at end of file From c2039f008ac04fe8aa1cee5a4df5af29e931e56b Mon Sep 17 00:00:00 2001 From: jkuehner Date: Tue, 17 Mar 2026 12:02:23 +0100 Subject: [PATCH 5/6] fix the github issues --- src/parse/index.ts | 85 ++++++++++++++++++++++++++++++++++----- src/stringify/compiler.ts | 18 ++++----- 2 files changed, 84 insertions(+), 19 deletions(-) diff --git a/src/parse/index.ts b/src/parse/index.ts index ca5a9c7e..32a3a1ad 100644 --- a/src/parse/index.ts +++ b/src/parse/index.ts @@ -29,8 +29,8 @@ import { type CssStartingStyleAST, type CssStylesheetAST, type CssSupportsAST, - type CssViewTransitionAST, CssTypes, + type CssViewTransitionAST, } from '../type'; import { indexOfArrayWithBracketAndQuoteSupport, @@ -156,12 +156,32 @@ export const parse = ( const rules: Array = []; whitespace(); comments(rules); - while (css.length && css.charAt(0) !== '}') { + while (css.length) { + if (css.charAt(0) === '}') { + if (options?.silent) { + // Skip stray closing braces at top level + error("extra '}'"); + const fakeMatch = ['}'] as unknown as RegExpExecArray; + processMatch(fakeMatch); + whitespace(); + comments(rules); + continue; + } + break; + } node = atRule() || rule(); if (node) { rules.push(node); comments(rules); } else { + if (options?.silent) { + // Skip unrecognized character to recover + const fakeMatch = [css.charAt(0)] as unknown as RegExpExecArray; + processMatch(fakeMatch); + whitespace(); + comments(rules); + continue; + } break; } } @@ -311,6 +331,27 @@ export const parse = ( comments(decls); decl = declaration(); } + // In silent mode, try to recover from errors by skipping to next semicolon + while (options?.silent && css.length && css.charAt(0) !== '}') { + const semiPos = css.indexOf(';'); + const bracePos = css.indexOf('}'); + if (semiPos !== -1 && (bracePos === -1 || semiPos < bracePos)) { + const fakeMatch = [ + css.substring(0, semiPos + 1), + ] as unknown as RegExpExecArray; + processMatch(fakeMatch); + whitespace(); + comments(decls); + decl = declaration(); + while (decl) { + decls.push(decl); + comments(decls); + decl = declaration(); + } + } else { + break; + } + } if (!close()) { return error("missing '}'"); @@ -382,7 +423,20 @@ export const parse = ( continue; } - // nothing matched + // nothing matched — skip to next semicolon or closing brace to recover + if (options?.silent) { + const semiPos = css.indexOf(';'); + const bracePos = css.indexOf('}'); + if (semiPos !== -1 && (bracePos === -1 || semiPos < bracePos)) { + const fakeMatch = [ + css.substring(0, semiPos + 1), + ] as unknown as RegExpExecArray; + processMatch(fakeMatch); + whitespace(); + comments(items); + continue; + } + } break; } @@ -398,9 +452,7 @@ export const parse = ( * both top-level rules and declarations when nested inside a rule. */ function rulesOrDeclarations() { - const items: Array< - CssAtRuleAST | CssDeclarationAST | CssCommentAST - > = []; + const items: Array = []; whitespace(); comments(items); while (css.length && css.charAt(0) !== '}') { @@ -432,7 +484,20 @@ export const parse = ( continue; } - // nothing matched + // nothing matched — skip to next semicolon or closing brace to recover + if (options?.silent) { + const semiPos = css.indexOf(';'); + const bracePos = css.indexOf('}'); + if (semiPos !== -1 && (bracePos === -1 || semiPos < bracePos)) { + const fakeMatch = [ + css.substring(0, semiPos + 1), + ] as unknown as RegExpExecArray; + processMatch(fakeMatch); + whitespace(); + comments(items); + continue; + } + } break; } return items; @@ -703,7 +768,7 @@ export const parse = ( 'right-bottom', ]; const pageMarginBoxRegex = new RegExp( - '^@(' + pageMarginBoxNames.join('|') + ')(?![\\w-])\\s*', + `^@(${pageMarginBoxNames.join('|')})(?![\\w-])\\s*`, ); function atPageMarginBox(): CssPageMarginBoxAST | undefined { @@ -1118,7 +1183,9 @@ export const parse = ( const preludeEnd = indexOfArrayWithBracketAndQuoteSupport(css, ['{', ';']); if (preludeEnd !== -1 && preludeEnd > 0) { prelude = trim(css.substring(0, preludeEnd)); - const fakeMatch = [css.substring(0, preludeEnd)] as unknown as RegExpExecArray; + const fakeMatch = [ + css.substring(0, preludeEnd), + ] as unknown as RegExpExecArray; processMatch(fakeMatch); } diff --git a/src/stringify/compiler.ts b/src/stringify/compiler.ts index 7c94d0f3..960788ea 100644 --- a/src/stringify/compiler.ts +++ b/src/stringify/compiler.ts @@ -27,8 +27,8 @@ import { type CssStartingStyleAST, type CssStylesheetAST, type CssSupportsAST, - type CssViewTransitionAST, CssTypes, + type CssViewTransitionAST, } from '../type'; export type CompilerOptions = { @@ -510,10 +510,7 @@ class Compiler { fontFeatureValues(node: CssFontFeatureValuesAST) { if (this.compress) { return ( - this.emit( - `@font-feature-values ${node.fontFamily}`, - node.position, - ) + + this.emit(`@font-feature-values ${node.fontFamily}`, node.position) + this.emit('{') + this.mapVisit(node.rules) + this.emit('}') @@ -617,8 +614,7 @@ class Compiler { ); } const hasNestedRules = node.rules.some( - (r) => - r.type !== CssTypes.declaration && r.type !== CssTypes.comment, + (r) => r.type !== CssTypes.declaration && r.type !== CssTypes.comment, ); const delim = hasNestedRules ? '\n\n' : '\n'; return ( @@ -626,9 +622,11 @@ class Compiler { this.emit(hasNestedRules ? ` {\n${this.indent(1)}` : ' {\n') + this.emit(hasNestedRules ? '' : this.indent(1)) + this.mapVisit(node.rules, delim) + - this.emit(hasNestedRules - ? `\n${this.indent(-1)}${this.indent()}}` - : `${this.indent(-1)}\n${this.indent()}}`) + this.emit( + hasNestedRules + ? `\n${this.indent(-1)}${this.indent()}}` + : `${this.indent(-1)}\n${this.indent()}}`, + ) ); } From 02c2412369d006e89f6d381094b954bdf7f9851d Mon Sep 17 00:00:00 2001 From: jkuehner Date: Tue, 17 Mar 2026 12:05:26 +0100 Subject: [PATCH 6/6] add tests for the github issues --- test/parse.test.ts | 253 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 251 insertions(+), 2 deletions(-) diff --git a/test/parse.test.ts b/test/parse.test.ts index 9f6eedc4..78e5be80 100644 --- a/test/parse.test.ts +++ b/test/parse.test.ts @@ -1,6 +1,13 @@ import type CssParseError from '../src/CssParseError'; -import { parse } from '../src/index'; -import type { CssMediaAST, CssRuleAST } from '../src/type'; +import { parse, stringify } from '../src/index'; +import { + type CssDeclarationAST, + type CssMediaAST, + type CssPageAST, + type CssPageMarginBoxAST, + type CssRuleAST, + CssTypes, +} from '../src/type'; describe('parse(str)', () => { it('should save the filename and source', () => { @@ -111,4 +118,246 @@ describe('parse(str)', () => { decl = rule.declarations[0]; expect(decl.parent).toBe(rule); }); + + // GitHub Issue #210: @page with @left-middle crashes parser + // https://github.com/adobe/css-tools/issues/210 + describe('issue #210: @page with margin box at-rules', () => { + it('should parse @page with @left-middle without crashing', () => { + const css = '@page { margin: 2cm; @left-middle { content: "Hello"; } }'; + const ast = parse(css); + const page = ast.stylesheet.rules[0] as CssPageAST; + + expect(page.type).toBe(CssTypes.page); + expect(page.declarations.length).toBe(2); + + const marginDecl = page.declarations[0] as CssDeclarationAST; + expect(marginDecl.type).toBe(CssTypes.declaration); + expect(marginDecl.property).toBe('margin'); + expect(marginDecl.value).toBe('2cm'); + + const marginBox = page.declarations[1] as CssPageMarginBoxAST; + expect(marginBox.type).toBe(CssTypes.pageMarginBox); + expect(marginBox.name).toBe('left-middle'); + expect(marginBox.declarations.length).toBe(1); + expect((marginBox.declarations[0] as CssDeclarationAST).property).toBe( + 'content', + ); + }); + + it('should parse all 16 page margin box at-rules', () => { + const marginBoxNames = [ + 'top-left-corner', + 'top-left', + 'top-center', + 'top-right', + 'top-right-corner', + 'bottom-left-corner', + 'bottom-left', + 'bottom-center', + 'bottom-right', + 'bottom-right-corner', + 'left-top', + 'left-middle', + 'left-bottom', + 'right-top', + 'right-middle', + 'right-bottom', + ]; + + for (const name of marginBoxNames) { + const css = `@page { @${name} { content: "x"; } }`; + const ast = parse(css); + const page = ast.stylesheet.rules[0] as CssPageAST; + const box = page.declarations[0] as CssPageMarginBoxAST; + expect(box.type).toBe(CssTypes.pageMarginBox); + expect(box.name).toBe(name); + } + }); + + it('should roundtrip @page with margin boxes', () => { + const css = + '@page :first {\n margin: 2cm;\n @top-center {\n content: "Title";\n }\n @bottom-center {\n content: counter(page);\n }\n}'; + expect(stringify(parse(css))).toBe(css); + }); + }); + + // GitHub Issue #122: CSS nesting support + // https://github.com/adobe/css-tools/issues/122 + describe('issue #122: CSS nesting', () => { + it('should parse nested rules', () => { + const css = '.parent { color: red; .child { color: blue; } }'; + const ast = parse(css); + const rule = ast.stylesheet.rules[0] as CssRuleAST; + + expect(rule.selectors).toEqual(['.parent']); + expect(rule.declarations.length).toBe(2); + + const decl = rule.declarations[0] as CssDeclarationAST; + expect(decl.property).toBe('color'); + expect(decl.value).toBe('red'); + + const nested = rule.declarations[1] as CssRuleAST; + expect(nested.type).toBe(CssTypes.rule); + expect(nested.selectors).toEqual(['.child']); + expect((nested.declarations[0] as CssDeclarationAST).value).toBe('blue'); + }); + + it('should parse deeply nested rules', () => { + const css = '.a { .b { .c { color: red; } } }'; + const ast = parse(css); + const a = ast.stylesheet.rules[0] as CssRuleAST; + const b = a.declarations[0] as CssRuleAST; + const c = b.declarations[0] as CssRuleAST; + + expect(a.selectors).toEqual(['.a']); + expect(b.selectors).toEqual(['.b']); + expect(c.selectors).toEqual(['.c']); + expect((c.declarations[0] as CssDeclarationAST).value).toBe('red'); + }); + + it('should parse & selector nesting', () => { + const css = 'a { &:hover { color: red; } &::before { content: "x"; } }'; + const ast = parse(css); + const rule = ast.stylesheet.rules[0] as CssRuleAST; + + expect(rule.declarations.length).toBe(2); + expect((rule.declarations[0] as CssRuleAST).selectors).toEqual([ + '&:hover', + ]); + expect((rule.declarations[1] as CssRuleAST).selectors).toEqual([ + '&::before', + ]); + }); + + it('should parse nested @media inside a rule', () => { + const css = + '.card { padding: 1rem; @media (min-width: 768px) { padding: 2rem; } }'; + const ast = parse(css); + const rule = ast.stylesheet.rules[0] as CssRuleAST; + + expect(rule.declarations.length).toBe(2); + const media = rule.declarations[1] as CssMediaAST; + expect(media.type).toBe(CssTypes.media); + expect(media.media).toBe('(min-width: 768px)'); + }); + + it('should roundtrip nested CSS', () => { + const css = + '.parent {\n color: red;\n .child {\n color: blue;\n }\n}'; + expect(stringify(parse(css))).toBe(css); + }); + + it('should handle declarations after nested rules', () => { + const css = '.a { .b { color: red; } margin: 0; }'; + const ast = parse(css); + const rule = ast.stylesheet.rules[0] as CssRuleAST; + expect(rule.declarations.length).toBe(2); + expect((rule.declarations[0] as CssRuleAST).selectors).toEqual(['.b']); + expect((rule.declarations[1] as CssDeclarationAST).property).toBe( + 'margin', + ); + }); + }); + + // GitHub Issue #175: Comment with { in selector causes parse failure + // https://github.com/adobe/css-tools/issues/175 + describe('issue #175: comments with braces in selectors', () => { + it('should parse selector with commented-out parts containing braces', () => { + const css = 'head, /* footer, */body/*, nav */ { foo: bar; }'; + const ast = parse(css); + const rule = ast.stylesheet.rules[0] as CssRuleAST; + + expect(rule.selectors).toEqual(['head', 'body']); + expect(rule.declarations.length).toBe(1); + expect((rule.declarations[0] as CssDeclarationAST).property).toBe('foo'); + expect((rule.declarations[0] as CssDeclarationAST).value).toBe('bar'); + }); + + it('should parse selector with comment before opening brace', () => { + const css = '.a /* comment */ { color: red; }'; + const ast = parse(css); + const rule = ast.stylesheet.rules[0] as CssRuleAST; + + expect(rule.selectors).toEqual(['.a']); + expect((rule.declarations[0] as CssDeclarationAST).value).toBe('red'); + }); + + it('should roundtrip selector with comments stripped', () => { + const css = 'head, /* footer, */body { color: red; }'; + const output = stringify(parse(css)); + expect(output).toContain('head,'); + expect(output).toContain('body'); + expect(output).toContain('color: red'); + expect(output).not.toContain('footer'); + }); + }); + + // GitHub Issue #188: Stylesheets with errors / silent mode recovery + // https://github.com/adobe/css-tools/issues/188 + describe('issue #188: error recovery in silent mode', () => { + it('should recover valid declarations after invalid ones', () => { + const css = '* { aa; display: block; }'; + const ast = parse(css, { silent: true }); + const rule = ast.stylesheet.rules[0] as CssRuleAST; + + expect(rule.selectors).toEqual(['*']); + expect(rule.declarations.length).toBe(1); + expect((rule.declarations[0] as CssDeclarationAST).property).toBe( + 'display', + ); + expect((rule.declarations[0] as CssDeclarationAST).value).toBe('block'); + }); + + it('should continue parsing rules after error recovery', () => { + const css = '.broken { badprop; } .ok { color: red; }'; + const ast = parse(css, { silent: true }); + const rules = ast.stylesheet.rules; + + expect(rules.length).toBe(2); + const okRule = rules[1] as CssRuleAST; + expect(okRule.selectors).toEqual(['.ok']); + expect((okRule.declarations[0] as CssDeclarationAST).value).toBe('red'); + }); + + it('should recover from extra closing braces', () => { + const css = '.a {} } .b { color: blue; }'; + const ast = parse(css, { silent: true }); + const rules = ast.stylesheet.rules; + + expect(rules.length).toBe(2); + expect((rules[0] as CssRuleAST).selectors).toEqual(['.a']); + expect((rules[1] as CssRuleAST).selectors).toEqual(['.b']); + expect( + ((rules[1] as CssRuleAST).declarations[0] as CssDeclarationAST).value, + ).toBe('blue'); + }); + + it('should recover from multiple errors in one rule', () => { + const css = '.x { bad1; bad2; color: green; font-size: 1rem; }'; + const ast = parse(css, { silent: true }); + const rule = ast.stylesheet.rules[0] as CssRuleAST; + + expect(rule.selectors).toEqual(['.x']); + const decls = rule.declarations.filter( + (d) => d.type === CssTypes.declaration, + ) as CssDeclarationAST[]; + expect(decls.length).toBe(2); + expect(decls[0].property).toBe('color'); + expect(decls[1].property).toBe('font-size'); + }); + + it('should record parsing errors', () => { + const css = '* { aa; display: block; }'; + const ast = parse(css, { silent: true }); + + expect(ast.stylesheet.parsingErrors).toBeDefined(); + expect(ast.stylesheet.parsingErrors?.length).toBeGreaterThan(0); + }); + + it('should not recover when silent is false', () => { + expect(() => { + parse('* { aa; display: block; }'); + }).toThrow(); + }); + }); });