| // @ts-check |
| |
| import { test } from 'uvu'; |
| import * as assert from 'uvu/assert'; |
| import {webcrypto as crypto} from 'node:crypto'; |
| |
| let transform, composeVisitors; |
| if (process.env.TEST_WASM === 'node') { |
| ({transform, composeVisitors} = await import('../../wasm/wasm-node.mjs')); |
| } else if (process.env.TEST_WASM === 'browser') { |
| // Define crypto globally for old node. |
| // @ts-ignore |
| globalThis.crypto ??= crypto; |
| let wasm = await import('../../wasm/index.mjs'); |
| await wasm.default(); |
| ({transform, composeVisitors} = wasm); |
| } else { |
| ({transform, composeVisitors} = await import('../index.mjs')); |
| } |
| |
| test('different types', () => { |
| let res = transform({ |
| filename: 'test.css', |
| minify: true, |
| code: Buffer.from(` |
| .foo { |
| width: 16px; |
| color: red; |
| } |
| `), |
| visitor: composeVisitors([ |
| { |
| Length(l) { |
| if (l.unit === 'px') { |
| return { |
| unit: 'rem', |
| value: l.value / 16 |
| } |
| } |
| } |
| }, |
| { |
| Color(c) { |
| if (c.type === 'rgb') { |
| return { |
| type: 'rgb', |
| r: c.g, |
| g: c.r, |
| b: c.b, |
| alpha: c.alpha |
| }; |
| } |
| } |
| } |
| ]) |
| }); |
| |
| assert.equal(res.code.toString(), '.foo{color:#0f0;width:1rem}'); |
| }); |
| |
| test('simple matching types', () => { |
| let res = transform({ |
| filename: 'test.css', |
| minify: true, |
| code: Buffer.from(` |
| .foo { |
| width: 16px; |
| } |
| `), |
| visitor: composeVisitors([ |
| { |
| Length(l) { |
| return { |
| unit: l.unit, |
| value: l.value * 2 |
| }; |
| } |
| }, |
| { |
| Length(l) { |
| if (l.unit === 'px') { |
| return { |
| unit: 'rem', |
| value: l.value / 16 |
| } |
| } |
| } |
| } |
| ]) |
| }); |
| |
| assert.equal(res.code.toString(), '.foo{width:2rem}'); |
| }); |
| |
| test('different properties', () => { |
| let res = transform({ |
| filename: 'test.css', |
| minify: true, |
| code: Buffer.from(` |
| .foo { |
| size: 16px; |
| bg: #ff0; |
| } |
| `), |
| visitor: composeVisitors([ |
| { |
| Declaration: { |
| custom: { |
| size(v) { |
| return [ |
| { property: 'unparsed', value: { propertyId: { property: 'width' }, value: v.value } }, |
| { property: 'unparsed', value: { propertyId: { property: 'height' }, value: v.value } } |
| ]; |
| } |
| } |
| } |
| }, |
| { |
| Declaration: { |
| custom: { |
| bg(v) { |
| if (v.value[0].type === 'color') { |
| return { property: 'background-color', value: v.value[0].value }; |
| } |
| } |
| } |
| } |
| } |
| ]) |
| }); |
| |
| assert.equal(res.code.toString(), '.foo{width:16px;height:16px;background-color:#ff0}'); |
| }); |
| |
| test('composed properties', () => { |
| /** @type {import('../index').Visitor[]} */ |
| let visitors = [ |
| { |
| Declaration: { |
| custom: { |
| size(v) { |
| if (v.value[0].type === 'length') { |
| return [ |
| { property: 'width', value: { type: 'length-percentage', value: { type: 'dimension', value: v.value[0].value } } }, |
| { property: 'height', value: { type: 'length-percentage', value: { type: 'dimension', value: v.value[0].value } } }, |
| ]; |
| } |
| } |
| } |
| } |
| }, |
| { |
| Declaration: { |
| width() { |
| return []; |
| } |
| } |
| } |
| ]; |
| |
| // Check that it works in any order. |
| for (let i = 0; i < 2; i++) { |
| let res = transform({ |
| filename: 'test.css', |
| minify: true, |
| code: Buffer.from(` |
| .foo { |
| size: 16px; |
| } |
| `), |
| visitor: composeVisitors(visitors) |
| }); |
| |
| assert.equal(res.code.toString(), '.foo{height:16px}'); |
| visitors.reverse(); |
| } |
| }); |
| |
| test('same properties', () => { |
| let res = transform({ |
| filename: 'test.css', |
| minify: true, |
| code: Buffer.from(` |
| .foo { |
| color: red; |
| } |
| `), |
| visitor: composeVisitors([ |
| { |
| Declaration: { |
| color(v) { |
| if (v.property === 'color' && v.value.type === 'rgb') { |
| return { |
| property: 'color', |
| value: { |
| type: 'rgb', |
| r: v.value.g, |
| g: v.value.r, |
| b: v.value.b, |
| alpha: v.value.alpha |
| } |
| }; |
| } |
| } |
| } |
| }, |
| { |
| Declaration: { |
| color(v) { |
| if (v.property === 'color' && v.value.type === 'rgb' && v.value.g > 0) { |
| v.value.alpha /= 2; |
| } |
| return v; |
| } |
| } |
| } |
| ]) |
| }); |
| |
| assert.equal(res.code.toString(), '.foo{color:#00ff0080}'); |
| }); |
| |
| test('properties plus values', () => { |
| let res = transform({ |
| filename: 'test.css', |
| minify: true, |
| code: Buffer.from(` |
| .foo { |
| size: test; |
| } |
| `), |
| visitor: composeVisitors([ |
| { |
| Declaration: { |
| custom: { |
| size() { |
| return [ |
| { property: 'width', value: { type: 'length-percentage', value: { type: 'dimension', value: { unit: 'px', value: 32 } } } }, |
| { property: 'height', value: { type: 'length-percentage', value: { type: 'dimension', value: { unit: 'px', value: 32 } } } }, |
| ]; |
| } |
| } |
| } |
| }, |
| { |
| Length(l) { |
| if (l.unit === 'px') { |
| return { |
| unit: 'rem', |
| value: l.value / 16 |
| } |
| } |
| } |
| } |
| ]) |
| }); |
| |
| assert.equal(res.code.toString(), '.foo{width:2rem;height:2rem}'); |
| }); |
| |
| test('unparsed properties', () => { |
| let res = transform({ |
| filename: 'test.css', |
| minify: true, |
| code: Buffer.from(` |
| .foo { |
| width: test; |
| } |
| .bar { |
| width: 16px; |
| } |
| `), |
| visitor: composeVisitors([ |
| { |
| Declaration: { |
| width(v) { |
| if (v.property === 'unparsed') { |
| return [ |
| { property: 'width', value: { type: 'length-percentage', value: { type: 'dimension', value: { unit: 'px', value: 32 } } } }, |
| { property: 'height', value: { type: 'length-percentage', value: { type: 'dimension', value: { unit: 'px', value: 32 } } } }, |
| ]; |
| } |
| } |
| } |
| }, |
| { |
| Length(l) { |
| if (l.unit === 'px') { |
| return { |
| unit: 'rem', |
| value: l.value / 16 |
| } |
| } |
| } |
| } |
| ]) |
| }); |
| |
| assert.equal(res.code.toString(), '.foo{width:2rem;height:2rem}.bar{width:1rem}'); |
| }); |
| |
| test('returning unparsed properties', () => { |
| let res = transform({ |
| filename: 'test.css', |
| minify: true, |
| code: Buffer.from(` |
| .foo { |
| width: test; |
| } |
| `), |
| visitor: composeVisitors([ |
| { |
| Declaration: { |
| width(v) { |
| if (v.property === 'unparsed' && v.value.value[0].type === 'token' && v.value.value[0].value.type === 'ident') { |
| return { |
| property: 'unparsed', |
| value: { |
| propertyId: { property: 'width' }, |
| value: [{ |
| type: 'var', |
| value: { |
| name: { |
| ident: '--' + v.value.value[0].value.value |
| } |
| } |
| }] |
| } |
| } |
| } |
| } |
| } |
| }, |
| { |
| Declaration: { |
| width(v) { |
| if (v.property === 'unparsed') { |
| return { |
| property: 'unparsed', |
| value: { |
| propertyId: { property: 'width' }, |
| value: [{ |
| type: 'function', |
| value: { |
| name: 'calc', |
| arguments: v.value.value |
| } |
| }] |
| } |
| } |
| } |
| } |
| } |
| } |
| ]) |
| }); |
| |
| assert.equal(res.code.toString(), '.foo{width:calc(var(--test))}'); |
| }); |
| |
| test('all property handlers', () => { |
| let res = transform({ |
| filename: 'test.css', |
| minify: true, |
| code: Buffer.from(` |
| .foo { |
| width: test; |
| height: test; |
| } |
| `), |
| visitor: composeVisitors([ |
| { |
| Declaration(decl) { |
| if (decl.property === 'unparsed' && decl.value.propertyId.property === 'width') { |
| return { property: 'width', value: { type: 'length-percentage', value: { type: 'dimension', value: { unit: 'px', value: 32 } } } }; |
| } |
| } |
| }, |
| { |
| Declaration(decl) { |
| if (decl.property === 'unparsed' && decl.value.propertyId.property === 'height') { |
| return { property: 'height', value: { type: 'length-percentage', value: { type: 'dimension', value: { unit: 'px', value: 32 } } } }; |
| } |
| } |
| } |
| ]) |
| }); |
| |
| assert.equal(res.code.toString(), '.foo{width:32px;height:32px}'); |
| }); |
| |
| test('all property handlers (exit)', () => { |
| let res = transform({ |
| filename: 'test.css', |
| minify: true, |
| code: Buffer.from(` |
| .foo { |
| width: test; |
| height: test; |
| } |
| `), |
| visitor: composeVisitors([ |
| { |
| DeclarationExit(decl) { |
| if (decl.property === 'unparsed' && decl.value.propertyId.property === 'width') { |
| return { property: 'width', value: { type: 'length-percentage', value: { type: 'dimension', value: { unit: 'px', value: 32 } } } }; |
| } |
| } |
| }, |
| { |
| DeclarationExit(decl) { |
| if (decl.property === 'unparsed' && decl.value.propertyId.property === 'height') { |
| return { property: 'height', value: { type: 'length-percentage', value: { type: 'dimension', value: { unit: 'px', value: 32 } } } }; |
| } |
| } |
| } |
| ]) |
| }); |
| |
| assert.equal(res.code.toString(), '.foo{width:32px;height:32px}'); |
| }); |
| |
| test('tokens and functions', () => { |
| let res = transform({ |
| filename: 'test.css', |
| minify: true, |
| code: Buffer.from(` |
| .foo { |
| width: f3(f2(f1(test))); |
| } |
| `), |
| visitor: composeVisitors([ |
| { |
| FunctionExit: { |
| f1(f) { |
| if (f.arguments.length === 1 && f.arguments[0].type === 'token' && f.arguments[0].value.type === 'ident') { |
| return { |
| type: 'length', |
| value: { |
| unit: 'px', |
| value: 32 |
| } |
| } |
| } |
| } |
| } |
| }, |
| { |
| FunctionExit(f) { |
| return f.arguments[0]; |
| } |
| }, |
| { |
| Length(l) { |
| if (l.unit === 'px') { |
| return { |
| unit: 'rem', |
| value: l.value / 16 |
| } |
| } |
| } |
| } |
| ]) |
| }); |
| |
| assert.equal(res.code.toString(), '.foo{width:2rem}'); |
| }); |
| |
| test('unknown rules', () => { |
| let declared = new Map(); |
| let res = transform({ |
| filename: 'test.css', |
| minify: true, |
| code: Buffer.from(` |
| @test #056ef0; |
| |
| .menu_link { |
| background: @blue; |
| } |
| `), |
| visitor: composeVisitors([ |
| { |
| Rule: { |
| unknown: { |
| test(rule) { |
| rule.name = 'blue'; |
| return { |
| type: 'unknown', |
| value: rule |
| }; |
| } |
| } |
| } |
| }, |
| { |
| Rule: { |
| unknown(rule) { |
| declared.set(rule.name, rule.prelude); |
| return []; |
| } |
| }, |
| Token: { |
| 'at-keyword'(token) { |
| if (declared.has(token.value)) { |
| return declared.get(token.value); |
| } |
| } |
| } |
| } |
| ]) |
| }); |
| |
| assert.equal(res.code.toString(), '.menu_link{background:#056ef0}'); |
| }); |
| |
| test('custom at rules', () => { |
| let res = transform({ |
| filename: 'test.css', |
| minify: true, |
| code: Buffer.from(` |
| @testA; |
| @testB; |
| `), |
| customAtRules: { |
| testA: {}, |
| testB: {} |
| }, |
| visitor: composeVisitors([ |
| { |
| Rule: { |
| custom: { |
| testA(rule) { |
| return { |
| type: 'style', |
| value: { |
| loc: rule.loc, |
| selectors: [ |
| [{ type: 'class', name: 'testA' }] |
| ], |
| declarations: { |
| declarations: [ |
| { |
| property: 'color', |
| value: { |
| type: 'rgb', |
| r: 0xff, |
| g: 0x00, |
| b: 0x00, |
| alpha: 1, |
| } |
| } |
| ] |
| } |
| } |
| }; |
| } |
| } |
| } |
| }, |
| { |
| Rule: { |
| custom: { |
| testB(rule) { |
| return { |
| type: 'style', |
| value: { |
| loc: rule.loc, |
| selectors: [ |
| [{ type: 'class', name: 'testB' }] |
| ], |
| declarations: { |
| declarations: [ |
| { |
| property: 'color', |
| value: { |
| type: 'rgb', |
| r: 0x00, |
| g: 0xff, |
| b: 0x00, |
| alpha: 1, |
| } |
| } |
| ] |
| } |
| } |
| }; |
| } |
| } |
| } |
| } |
| ]) |
| }); |
| |
| assert.equal(res.code.toString(), '.testA{color:red}.testB{color:#0f0}'); |
| }); |
| |
| test('known rules', () => { |
| let declared = new Map(); |
| let res = transform({ |
| filename: 'test.css', |
| minify: true, |
| code: Buffer.from(` |
| .test:focus-visible { |
| margin-left: 20px; |
| margin-right: @margin-left; |
| } |
| `), |
| targets: { |
| safari: 14 << 16 |
| }, |
| visitor: composeVisitors([ |
| { |
| Rule: { |
| style(rule) { |
| let valuesByProperty = new Map(); |
| for (let decl of rule.value.declarations.declarations) { |
| /** @type string */ |
| let name = decl.property; |
| if (decl.property === 'unparsed') { |
| name = decl.value.propertyId.property; |
| } |
| valuesByProperty.set(name, decl); |
| } |
| |
| rule.value.declarations.declarations = rule.value.declarations.declarations.map(decl => { |
| // Only single value supported. Would need a way to convert parsed values to unparsed tokens otherwise. |
| if (decl.property === 'unparsed' && decl.value.value.length === 1) { |
| let token = decl.value.value[0]; |
| if (token.type === 'token' && token.value.type === 'at-keyword' && valuesByProperty.has(token.value.value)) { |
| let v = valuesByProperty.get(token.value.value); |
| return { |
| /** @type any */ |
| property: decl.value.propertyId.property, |
| value: v.value |
| }; |
| } |
| } |
| return decl; |
| }); |
| |
| return rule; |
| } |
| } |
| }, |
| { |
| Rule: { |
| style(rule) { |
| let clone = null; |
| for (let selector of rule.value.selectors) { |
| for (let [i, component] of selector.entries()) { |
| if (component.type === 'pseudo-class' && component.kind === 'focus-visible') { |
| if (clone == null) { |
| clone = [...rule.value.selectors.map(s => [...s])]; |
| } |
| |
| selector[i] = { type: 'class', name: 'focus-visible' }; |
| } |
| } |
| } |
| |
| if (clone) { |
| return [rule, { type: 'style', value: { ...rule.value, selectors: clone } }]; |
| } |
| } |
| } |
| } |
| ]) |
| }); |
| |
| assert.equal(res.code.toString(), '.test.focus-visible{margin-left:20px;margin-right:20px}.test:focus-visible{margin-left:20px;margin-right:20px}'); |
| }); |
| |
| test('environment variables', () => { |
| /** @type {Record<string, import('../ast').TokenOrValue>} */ |
| let tokens = { |
| '--branding-small': { |
| type: 'length', |
| value: { |
| unit: 'px', |
| value: 600 |
| } |
| }, |
| '--branding-padding': { |
| type: 'length', |
| value: { |
| unit: 'px', |
| value: 20 |
| } |
| } |
| }; |
| |
| let res = transform({ |
| filename: 'test.css', |
| minify: true, |
| errorRecovery: true, |
| code: Buffer.from(` |
| @media (max-width: env(--branding-small)) { |
| body { |
| padding: env(--branding-padding); |
| } |
| } |
| `), |
| visitor: composeVisitors([ |
| { |
| EnvironmentVariable: { |
| '--branding-small': () => tokens['--branding-small'] |
| } |
| }, |
| { |
| EnvironmentVariable: { |
| '--branding-padding': () => tokens['--branding-padding'] |
| } |
| } |
| ]) |
| }); |
| |
| assert.equal(res.code.toString(), '@media (width<=600px){body{padding:20px}}'); |
| }); |
| |
| test('variables', () => { |
| /** @type {Record<string, import('../ast').TokenOrValue>} */ |
| let tokens = { |
| '--branding-small': { |
| type: 'length', |
| value: { |
| unit: 'px', |
| value: 600 |
| } |
| }, |
| '--branding-padding': { |
| type: 'length', |
| value: { |
| unit: 'px', |
| value: 20 |
| } |
| } |
| }; |
| |
| let res = transform({ |
| filename: 'test.css', |
| minify: true, |
| errorRecovery: true, |
| code: Buffer.from(` |
| body { |
| padding: var(--branding-padding); |
| width: var(--branding-small); |
| } |
| `), |
| visitor: composeVisitors([ |
| { |
| Variable(v) { |
| if (v.name.ident === '--branding-small') { |
| return tokens['--branding-small']; |
| } |
| } |
| }, |
| { |
| Variable(v) { |
| if (v.name.ident === '--branding-padding') { |
| return tokens['--branding-padding']; |
| } |
| } |
| } |
| ]) |
| }); |
| |
| assert.equal(res.code.toString(), 'body{padding:20px;width:600px}'); |
| }); |
| |
| test('StyleSheet', () => { |
| let styleSheetCalledCount = 0; |
| let styleSheetExitCalledCount = 0; |
| transform({ |
| filename: 'test.css', |
| code: Buffer.from(` |
| body { |
| color: blue; |
| } |
| `), |
| visitor: composeVisitors([ |
| { |
| StyleSheet() { |
| styleSheetCalledCount++ |
| }, |
| StyleSheetExit() { |
| styleSheetExitCalledCount++ |
| } |
| }, |
| { |
| StyleSheet() { |
| styleSheetCalledCount++ |
| }, |
| StyleSheetExit() { |
| styleSheetExitCalledCount++ |
| } |
| } |
| ]) |
| }); |
| assert.equal(styleSheetCalledCount, 2); |
| assert.equal(styleSheetExitCalledCount, 2); |
| }); |
| |
| test.run(); |