|
1 | 1 | import type CssParseError from '../src/CssParseError'; |
2 | | -import { parse } from '../src/index'; |
3 | | -import type { CssMediaAST, CssRuleAST } from '../src/type'; |
| 2 | +import { parse, stringify } from '../src/index'; |
| 3 | +import { |
| 4 | + type CssDeclarationAST, |
| 5 | + type CssMediaAST, |
| 6 | + type CssPageAST, |
| 7 | + type CssPageMarginBoxAST, |
| 8 | + type CssRuleAST, |
| 9 | + CssTypes, |
| 10 | +} from '../src/type'; |
4 | 11 |
|
5 | 12 | describe('parse(str)', () => { |
6 | 13 | it('should save the filename and source', () => { |
@@ -111,4 +118,246 @@ describe('parse(str)', () => { |
111 | 118 | decl = rule.declarations[0]; |
112 | 119 | expect(decl.parent).toBe(rule); |
113 | 120 | }); |
| 121 | + |
| 122 | + // GitHub Issue #210: @page with @left-middle crashes parser |
| 123 | + // https://github.com/adobe/css-tools/issues/210 |
| 124 | + describe('issue #210: @page with margin box at-rules', () => { |
| 125 | + it('should parse @page with @left-middle without crashing', () => { |
| 126 | + const css = '@page { margin: 2cm; @left-middle { content: "Hello"; } }'; |
| 127 | + const ast = parse(css); |
| 128 | + const page = ast.stylesheet.rules[0] as CssPageAST; |
| 129 | + |
| 130 | + expect(page.type).toBe(CssTypes.page); |
| 131 | + expect(page.declarations.length).toBe(2); |
| 132 | + |
| 133 | + const marginDecl = page.declarations[0] as CssDeclarationAST; |
| 134 | + expect(marginDecl.type).toBe(CssTypes.declaration); |
| 135 | + expect(marginDecl.property).toBe('margin'); |
| 136 | + expect(marginDecl.value).toBe('2cm'); |
| 137 | + |
| 138 | + const marginBox = page.declarations[1] as CssPageMarginBoxAST; |
| 139 | + expect(marginBox.type).toBe(CssTypes.pageMarginBox); |
| 140 | + expect(marginBox.name).toBe('left-middle'); |
| 141 | + expect(marginBox.declarations.length).toBe(1); |
| 142 | + expect((marginBox.declarations[0] as CssDeclarationAST).property).toBe( |
| 143 | + 'content', |
| 144 | + ); |
| 145 | + }); |
| 146 | + |
| 147 | + it('should parse all 16 page margin box at-rules', () => { |
| 148 | + const marginBoxNames = [ |
| 149 | + 'top-left-corner', |
| 150 | + 'top-left', |
| 151 | + 'top-center', |
| 152 | + 'top-right', |
| 153 | + 'top-right-corner', |
| 154 | + 'bottom-left-corner', |
| 155 | + 'bottom-left', |
| 156 | + 'bottom-center', |
| 157 | + 'bottom-right', |
| 158 | + 'bottom-right-corner', |
| 159 | + 'left-top', |
| 160 | + 'left-middle', |
| 161 | + 'left-bottom', |
| 162 | + 'right-top', |
| 163 | + 'right-middle', |
| 164 | + 'right-bottom', |
| 165 | + ]; |
| 166 | + |
| 167 | + for (const name of marginBoxNames) { |
| 168 | + const css = `@page { @${name} { content: "x"; } }`; |
| 169 | + const ast = parse(css); |
| 170 | + const page = ast.stylesheet.rules[0] as CssPageAST; |
| 171 | + const box = page.declarations[0] as CssPageMarginBoxAST; |
| 172 | + expect(box.type).toBe(CssTypes.pageMarginBox); |
| 173 | + expect(box.name).toBe(name); |
| 174 | + } |
| 175 | + }); |
| 176 | + |
| 177 | + it('should roundtrip @page with margin boxes', () => { |
| 178 | + const css = |
| 179 | + '@page :first {\n margin: 2cm;\n @top-center {\n content: "Title";\n }\n @bottom-center {\n content: counter(page);\n }\n}'; |
| 180 | + expect(stringify(parse(css))).toBe(css); |
| 181 | + }); |
| 182 | + }); |
| 183 | + |
| 184 | + // GitHub Issue #122: CSS nesting support |
| 185 | + // https://github.com/adobe/css-tools/issues/122 |
| 186 | + describe('issue #122: CSS nesting', () => { |
| 187 | + it('should parse nested rules', () => { |
| 188 | + const css = '.parent { color: red; .child { color: blue; } }'; |
| 189 | + const ast = parse(css); |
| 190 | + const rule = ast.stylesheet.rules[0] as CssRuleAST; |
| 191 | + |
| 192 | + expect(rule.selectors).toEqual(['.parent']); |
| 193 | + expect(rule.declarations.length).toBe(2); |
| 194 | + |
| 195 | + const decl = rule.declarations[0] as CssDeclarationAST; |
| 196 | + expect(decl.property).toBe('color'); |
| 197 | + expect(decl.value).toBe('red'); |
| 198 | + |
| 199 | + const nested = rule.declarations[1] as CssRuleAST; |
| 200 | + expect(nested.type).toBe(CssTypes.rule); |
| 201 | + expect(nested.selectors).toEqual(['.child']); |
| 202 | + expect((nested.declarations[0] as CssDeclarationAST).value).toBe('blue'); |
| 203 | + }); |
| 204 | + |
| 205 | + it('should parse deeply nested rules', () => { |
| 206 | + const css = '.a { .b { .c { color: red; } } }'; |
| 207 | + const ast = parse(css); |
| 208 | + const a = ast.stylesheet.rules[0] as CssRuleAST; |
| 209 | + const b = a.declarations[0] as CssRuleAST; |
| 210 | + const c = b.declarations[0] as CssRuleAST; |
| 211 | + |
| 212 | + expect(a.selectors).toEqual(['.a']); |
| 213 | + expect(b.selectors).toEqual(['.b']); |
| 214 | + expect(c.selectors).toEqual(['.c']); |
| 215 | + expect((c.declarations[0] as CssDeclarationAST).value).toBe('red'); |
| 216 | + }); |
| 217 | + |
| 218 | + it('should parse & selector nesting', () => { |
| 219 | + const css = 'a { &:hover { color: red; } &::before { content: "x"; } }'; |
| 220 | + const ast = parse(css); |
| 221 | + const rule = ast.stylesheet.rules[0] as CssRuleAST; |
| 222 | + |
| 223 | + expect(rule.declarations.length).toBe(2); |
| 224 | + expect((rule.declarations[0] as CssRuleAST).selectors).toEqual([ |
| 225 | + '&:hover', |
| 226 | + ]); |
| 227 | + expect((rule.declarations[1] as CssRuleAST).selectors).toEqual([ |
| 228 | + '&::before', |
| 229 | + ]); |
| 230 | + }); |
| 231 | + |
| 232 | + it('should parse nested @media inside a rule', () => { |
| 233 | + const css = |
| 234 | + '.card { padding: 1rem; @media (min-width: 768px) { padding: 2rem; } }'; |
| 235 | + const ast = parse(css); |
| 236 | + const rule = ast.stylesheet.rules[0] as CssRuleAST; |
| 237 | + |
| 238 | + expect(rule.declarations.length).toBe(2); |
| 239 | + const media = rule.declarations[1] as CssMediaAST; |
| 240 | + expect(media.type).toBe(CssTypes.media); |
| 241 | + expect(media.media).toBe('(min-width: 768px)'); |
| 242 | + }); |
| 243 | + |
| 244 | + it('should roundtrip nested CSS', () => { |
| 245 | + const css = |
| 246 | + '.parent {\n color: red;\n .child {\n color: blue;\n }\n}'; |
| 247 | + expect(stringify(parse(css))).toBe(css); |
| 248 | + }); |
| 249 | + |
| 250 | + it('should handle declarations after nested rules', () => { |
| 251 | + const css = '.a { .b { color: red; } margin: 0; }'; |
| 252 | + const ast = parse(css); |
| 253 | + const rule = ast.stylesheet.rules[0] as CssRuleAST; |
| 254 | + expect(rule.declarations.length).toBe(2); |
| 255 | + expect((rule.declarations[0] as CssRuleAST).selectors).toEqual(['.b']); |
| 256 | + expect((rule.declarations[1] as CssDeclarationAST).property).toBe( |
| 257 | + 'margin', |
| 258 | + ); |
| 259 | + }); |
| 260 | + }); |
| 261 | + |
| 262 | + // GitHub Issue #175: Comment with { in selector causes parse failure |
| 263 | + // https://github.com/adobe/css-tools/issues/175 |
| 264 | + describe('issue #175: comments with braces in selectors', () => { |
| 265 | + it('should parse selector with commented-out parts containing braces', () => { |
| 266 | + const css = 'head, /* footer, */body/*, nav */ { foo: bar; }'; |
| 267 | + const ast = parse(css); |
| 268 | + const rule = ast.stylesheet.rules[0] as CssRuleAST; |
| 269 | + |
| 270 | + expect(rule.selectors).toEqual(['head', 'body']); |
| 271 | + expect(rule.declarations.length).toBe(1); |
| 272 | + expect((rule.declarations[0] as CssDeclarationAST).property).toBe('foo'); |
| 273 | + expect((rule.declarations[0] as CssDeclarationAST).value).toBe('bar'); |
| 274 | + }); |
| 275 | + |
| 276 | + it('should parse selector with comment before opening brace', () => { |
| 277 | + const css = '.a /* comment */ { color: red; }'; |
| 278 | + const ast = parse(css); |
| 279 | + const rule = ast.stylesheet.rules[0] as CssRuleAST; |
| 280 | + |
| 281 | + expect(rule.selectors).toEqual(['.a']); |
| 282 | + expect((rule.declarations[0] as CssDeclarationAST).value).toBe('red'); |
| 283 | + }); |
| 284 | + |
| 285 | + it('should roundtrip selector with comments stripped', () => { |
| 286 | + const css = 'head, /* footer, */body { color: red; }'; |
| 287 | + const output = stringify(parse(css)); |
| 288 | + expect(output).toContain('head,'); |
| 289 | + expect(output).toContain('body'); |
| 290 | + expect(output).toContain('color: red'); |
| 291 | + expect(output).not.toContain('footer'); |
| 292 | + }); |
| 293 | + }); |
| 294 | + |
| 295 | + // GitHub Issue #188: Stylesheets with errors / silent mode recovery |
| 296 | + // https://github.com/adobe/css-tools/issues/188 |
| 297 | + describe('issue #188: error recovery in silent mode', () => { |
| 298 | + it('should recover valid declarations after invalid ones', () => { |
| 299 | + const css = '* { aa; display: block; }'; |
| 300 | + const ast = parse(css, { silent: true }); |
| 301 | + const rule = ast.stylesheet.rules[0] as CssRuleAST; |
| 302 | + |
| 303 | + expect(rule.selectors).toEqual(['*']); |
| 304 | + expect(rule.declarations.length).toBe(1); |
| 305 | + expect((rule.declarations[0] as CssDeclarationAST).property).toBe( |
| 306 | + 'display', |
| 307 | + ); |
| 308 | + expect((rule.declarations[0] as CssDeclarationAST).value).toBe('block'); |
| 309 | + }); |
| 310 | + |
| 311 | + it('should continue parsing rules after error recovery', () => { |
| 312 | + const css = '.broken { badprop; } .ok { color: red; }'; |
| 313 | + const ast = parse(css, { silent: true }); |
| 314 | + const rules = ast.stylesheet.rules; |
| 315 | + |
| 316 | + expect(rules.length).toBe(2); |
| 317 | + const okRule = rules[1] as CssRuleAST; |
| 318 | + expect(okRule.selectors).toEqual(['.ok']); |
| 319 | + expect((okRule.declarations[0] as CssDeclarationAST).value).toBe('red'); |
| 320 | + }); |
| 321 | + |
| 322 | + it('should recover from extra closing braces', () => { |
| 323 | + const css = '.a {} } .b { color: blue; }'; |
| 324 | + const ast = parse(css, { silent: true }); |
| 325 | + const rules = ast.stylesheet.rules; |
| 326 | + |
| 327 | + expect(rules.length).toBe(2); |
| 328 | + expect((rules[0] as CssRuleAST).selectors).toEqual(['.a']); |
| 329 | + expect((rules[1] as CssRuleAST).selectors).toEqual(['.b']); |
| 330 | + expect( |
| 331 | + ((rules[1] as CssRuleAST).declarations[0] as CssDeclarationAST).value, |
| 332 | + ).toBe('blue'); |
| 333 | + }); |
| 334 | + |
| 335 | + it('should recover from multiple errors in one rule', () => { |
| 336 | + const css = '.x { bad1; bad2; color: green; font-size: 1rem; }'; |
| 337 | + const ast = parse(css, { silent: true }); |
| 338 | + const rule = ast.stylesheet.rules[0] as CssRuleAST; |
| 339 | + |
| 340 | + expect(rule.selectors).toEqual(['.x']); |
| 341 | + const decls = rule.declarations.filter( |
| 342 | + (d) => d.type === CssTypes.declaration, |
| 343 | + ) as CssDeclarationAST[]; |
| 344 | + expect(decls.length).toBe(2); |
| 345 | + expect(decls[0].property).toBe('color'); |
| 346 | + expect(decls[1].property).toBe('font-size'); |
| 347 | + }); |
| 348 | + |
| 349 | + it('should record parsing errors', () => { |
| 350 | + const css = '* { aa; display: block; }'; |
| 351 | + const ast = parse(css, { silent: true }); |
| 352 | + |
| 353 | + expect(ast.stylesheet.parsingErrors).toBeDefined(); |
| 354 | + expect(ast.stylesheet.parsingErrors?.length).toBeGreaterThan(0); |
| 355 | + }); |
| 356 | + |
| 357 | + it('should not recover when silent is false', () => { |
| 358 | + expect(() => { |
| 359 | + parse('* { aa; display: block; }'); |
| 360 | + }).toThrow(); |
| 361 | + }); |
| 362 | + }); |
114 | 363 | }); |
0 commit comments