From 44adad3fccd1db8d16e85cdc7479aa21abe92617 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sat, 7 Feb 2026 17:40:04 +0100 Subject: [PATCH 01/28] add test to ensure that !IMPORTANT is not marked as browserhack --- src/values/browserhacks.test.ts | 49 +++++++++++++++++---------------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/src/values/browserhacks.test.ts b/src/values/browserhacks.test.ts index 68432ce0..bc36997f 100644 --- a/src/values/browserhacks.test.ts +++ b/src/values/browserhacks.test.ts @@ -3,7 +3,7 @@ import { expect } from 'vitest' import { analyze } from '../index.js' test('finds hacks', () => { - const fixture = ` + const fixture = ` value-browserhacks { property: value !ie; property: value !test; @@ -11,36 +11,37 @@ test('finds hacks', () => { property: value\\9; } ` - const actual = analyze(fixture).values.browserhacks - const expected = { - total: 4, - totalUnique: 4, - unique: { - 'value !ie': 1, - 'value !test': 1, - 'value!nospace': 1, - "value\\9": 1, - }, - uniquenessRatio: 4 / 4 - } - expect(actual).toEqual(expected) + const actual = analyze(fixture).values.browserhacks + const expected = { + total: 4, + totalUnique: 4, + unique: { + 'value !ie': 1, + 'value !test': 1, + 'value!nospace': 1, + 'value\\9': 1, + }, + uniquenessRatio: 4 / 4, + } + expect(actual).toEqual(expected) }) test('reports no false positives', () => { - const fixture = ` + const fixture = ` value-browserhacks { property: value !important; content: '!important'; + margin: 0 !IMPORTANT; + margin: 0 !important; aspect-ratio: 16/9; } ` - const actual = analyze(fixture).values.browserhacks - const expected = { - total: 0, - totalUnique: 0, - unique: {}, - uniquenessRatio: 0, - } - expect(actual).toEqual(expected) + const actual = analyze(fixture).values.browserhacks + const expected = { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + } + expect(actual).toEqual(expected) }) - From 1ee3c6e8050637201cb94af4d7684ba53ff37f11 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sat, 7 Feb 2026 17:50:33 +0100 Subject: [PATCH 02/28] breaking: group property + value browserhacks by hack type --- src/index.ts | 9 +++------ src/properties/properties.test.ts | 11 +++++++---- src/values/browserhacks.test.ts | 13 ++++++------- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/index.ts b/src/index.ts index 967c37f3..5d43e450 100644 --- a/src/index.ts +++ b/src/index.ts @@ -452,10 +452,7 @@ function analyzeInternal(css: string, options: Options, useLo let declaration = node.text if (!declaration.toLowerCase().includes('!important')) { - let valueText = (node.value as CSSNode).text - let valueOffset = declaration.indexOf(valueText) - let stripSemi = declaration.slice(-1) === ';' - valueBrowserhacks.p(`${declaration.slice(valueOffset, stripSemi ? -1 : undefined)}`, toLoc(node.value as CSSNode)) + valueBrowserhacks.p('!ie', toLoc(node.value as CSSNode)) } if (inKeyframes) { @@ -491,7 +488,7 @@ function analyzeInternal(css: string, options: Options, useLo importantCustomProperties.p(property, propertyLoc) } } else if (is_browserhack) { - propertyHacks.p(property, propertyLoc) + propertyHacks.p(property.charAt(0), propertyLoc) propertyComplexities.push(2) } else { propertyComplexities.push(1) @@ -523,7 +520,7 @@ function analyzeInternal(css: string, options: Options, useLo // i.e. `property: value\9` if (isIe9Hack(value)) { - valueBrowserhacks.p(text, valueLoc) + valueBrowserhacks.p('\\9', valueLoc) text = text.slice(0, -2) complexity++ } diff --git a/src/properties/properties.test.ts b/src/properties/properties.test.ts index 6e4bdf15..80c1f9be 100644 --- a/src/properties/properties.test.ts +++ b/src/properties/properties.test.ts @@ -78,6 +78,8 @@ test('counts browser hacks', () => { hacks { margin: 0; *zoom: 1; + *margin: 0; + _property: 0; --custom: 1; } @@ -91,13 +93,14 @@ test('counts browser hacks', () => { ` const actual = analyze(fixture).properties.browserhacks const expected = { - '*zoom': 2, + '*': 3, + _: 1, } - expect(actual.total).toEqual(2) - expect(actual.totalUnique).toEqual(1) + expect(actual.total).toEqual(4) + expect(actual.totalUnique).toEqual(2) expect(actual.unique).toEqual(expected) - expect(actual.ratio).toEqual(2 / 4) + expect(actual.ratio).toEqual(4 / 6) }) test('counts custom properties', () => { diff --git a/src/values/browserhacks.test.ts b/src/values/browserhacks.test.ts index bc36997f..cf413106 100644 --- a/src/values/browserhacks.test.ts +++ b/src/values/browserhacks.test.ts @@ -6,6 +6,7 @@ test('finds hacks', () => { const fixture = ` value-browserhacks { property: value !ie; + property: value !IE; property: value !test; property: value!nospace; property: value\\9; @@ -13,15 +14,13 @@ test('finds hacks', () => { ` const actual = analyze(fixture).values.browserhacks const expected = { - total: 4, - totalUnique: 4, + total: 5, + totalUnique: 2, unique: { - 'value !ie': 1, - 'value !test': 1, - 'value!nospace': 1, - 'value\\9': 1, + '!ie': 4, + '\\9': 1, }, - uniquenessRatio: 4 / 4, + uniquenessRatio: 2 / 5, } expect(actual).toEqual(expected) }) From 01d158851311f7c496bc4b6f83046ef6c51b81c2 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sat, 7 Feb 2026 17:56:46 +0100 Subject: [PATCH 03/28] breaking: normalize `@layer` names and `@supports` browserhacks --- src/atrules/atrules.test.ts | 24 +++++++++--------------- src/atrules/atrules.ts | 27 +++++++++++++++++++++------ src/index.ts | 8 +++++--- 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/src/atrules/atrules.test.ts b/src/atrules/atrules.test.ts index 40fbaefc..d8c12718 100644 --- a/src/atrules/atrules.test.ts +++ b/src/atrules/atrules.test.ts @@ -14,17 +14,19 @@ test('counts total atrules', () => { @supports (display: grid) { a {} } + + @LAYER A; ` let actual = analyze(css).atrules - expect(actual.total).toEqual(4) + expect(actual.total).toEqual(5) expect(actual.totalUnique).toEqual(4) expect(actual.unique).toEqual({ import: 1, - layer: 1, + layer: 2, media: 1, supports: 1, }) - expect(actual.uniquenessRatio).toEqual(4 / 4) + expect(actual.uniquenessRatio).toEqual(4 / 5) }) test('calculates complexity', () => { @@ -538,19 +540,11 @@ test('finds @supports browserhacks', () => { const actual = result.atrules.supports.browserhacks const expected = { total: 10, - totalUnique: 10, - uniquenessRatio: 10 / 10, + totalUnique: 2, + uniquenessRatio: 2 / 10, unique: { - '(-webkit-appearance:none)': 1, - '(-webkit-appearance: none)': 1, - '(-moz-appearance:meterbar)': 1, - '(-moz-appearance:meterbar) and (display:flex)': 1, - '(-moz-appearance:meterbar) and (cursor:zoom-in)': 1, - '(-moz-appearance:meterbar) and (background-attachment:local)': 1, - '(-moz-appearance:meterbar) and (image-orientation:90deg)': 1, - '(-moz-appearance:meterbar) and (all:initial)': 1, - '(-moz-appearance:meterbar) and (list-style-type:japanese-formal)': 1, - '(-moz-appearance:meterbar) and (background-blend-mode:difference,normal)': 1, + '-webkit-appearance: none': 2, + '-moz-appearance: meterbar': 8, }, } diff --git a/src/atrules/atrules.ts b/src/atrules/atrules.ts index 0ba82c44..da91e193 100644 --- a/src/atrules/atrules.ts +++ b/src/atrules/atrules.ts @@ -1,12 +1,23 @@ -import { type CSSNode, str_equals, walk, BREAK, SUPPORTS_QUERY, MEDIA_TYPE, MEDIA_FEATURE, DIMENSION, NUMBER, IDENTIFIER } from '@projectwallace/css-parser' +import { + type CSSNode, + str_equals, + walk, + BREAK, + SUPPORTS_QUERY, + MEDIA_TYPE, + MEDIA_FEATURE, + DIMENSION, + NUMBER, + IDENTIFIER, +} from '@projectwallace/css-parser' /** * Check if an @supports atRule is a browserhack (Wallace parser version) * @param node - The Atrule CSSNode from Wallace parser * @returns true if the atrule is a browserhack */ -export function isSupportsBrowserhack(node: CSSNode): boolean { - let isBrowserhack = false +export function isSupportsBrowserhack(node: CSSNode): false | string { + let browserhack: false | string = false walk(node, function (n) { // Check SupportsQuery nodes for browserhack patterns @@ -15,14 +26,18 @@ export function isSupportsBrowserhack(node: CSSNode): boolean { const normalizedPrelude = prelude.toString().toLowerCase().replaceAll(/\s+/g, '') // Check for known browserhack patterns - if (normalizedPrelude.includes('-webkit-appearance:none') || normalizedPrelude.includes('-moz-appearance:meterbar')) { - isBrowserhack = true + if (normalizedPrelude.includes('-webkit-appearance:none')) { + browserhack = '-webkit-appearance: none' + return BREAK + } + if (normalizedPrelude.includes('-moz-appearance:meterbar')) { + browserhack = '-moz-appearance: meterbar' return BREAK } } }) - return isBrowserhack + return browserhack } /** diff --git a/src/index.ts b/src/index.ts index 5d43e450..a3065fca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -224,7 +224,7 @@ function analyzeInternal(css: string, options: Options, useLo let atruleLoc = toLoc(node) atruleNesting.push(depth) uniqueAtruleNesting.p(depth, atruleLoc) - atrules.p(node.name!, atruleLoc) + atrules.p(node.name?.toLowerCase()!, atruleLoc) //#region @FONT-FACE if (str_equals('font-face', node.name!)) { @@ -262,8 +262,10 @@ function analyzeInternal(css: string, options: Options, useLo } } else if (str_equals('supports', name)) { supports.p(node.prelude.text, toLoc(node)) - if (isSupportsBrowserhack(node.prelude)) { - supportsBrowserhacks.p(node.prelude.text, toLoc(node)) + + let hack = isSupportsBrowserhack(node.prelude) + if (hack) { + supportsBrowserhacks.p(hack, toLoc(node)) complexity++ } } else if (endsWith('keyframes', name)) { From 79f1f580748427e3c5404b38feb31d426b3b2c8a Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sat, 7 Feb 2026 20:21:50 +0100 Subject: [PATCH 04/28] breaking: normalize atrule names + charset name --- src/atrules/atrules.test.ts | 31 ++++++++++++++++++++++--------- src/index.ts | 28 ++++++++++++++-------------- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/src/atrules/atrules.test.ts b/src/atrules/atrules.test.ts index d8c12718..2899f50e 100644 --- a/src/atrules/atrules.test.ts +++ b/src/atrules/atrules.test.ts @@ -224,6 +224,11 @@ test('finds @font-face', () => { font-weight: bold; } + @FONT-FACE { + font-family: Upper; + src: url('upper.woff2'); + } + /* Duplicate @font-face in Media Query */ @media (min-width: 1000px) { @font-face { @@ -233,8 +238,8 @@ test('finds @font-face', () => { }` const actual = analyze(fixture).atrules.fontface const expected = { - total: 5, - totalUnique: 5, + total: 6, + totalUnique: 6, unique: [ { 'font-family': 'Arial', @@ -261,6 +266,10 @@ test('finds @font-face', () => { src: 'local("Helvetica Neue Bold"), local("HelveticaNeue-Bold"), url(MgOpenModernaBold.ttf)', 'font-weight': 'bold', }, + { + 'font-family': 'Upper', + src: `url('upper.woff2')`, + }, { 'font-family': "'Input Mono'", src: 'local(\'Input Mono\') url("https://url-to-input-mono.woff")', @@ -272,7 +281,7 @@ test('finds @font-face', () => { expect(actual).toEqual(expected) }) -test('finds @font-face', () => { +test('finds @font-face (with locations)', () => { const fixture = ` @font-face { font-family: Arial; @@ -421,11 +430,13 @@ test('finds @imports', () => { /* @import without prelude */ @import; + + @IMPORT url(test.css); ` const actual = analyze(fixture).atrules const expected = { - total: 10, - totalUnique: 10, + total: 11, + totalUnique: 11, unique: { '"https://example.com/without-url"': 1, 'url("https://example.com/with-url")': 1, @@ -437,6 +448,7 @@ test('finds @imports', () => { "'test.css' supports((display: grid))": 1, "'test.css' supports(not (display: grid))": 1, "'test.css' supports(selector(a:has(b)))": 1, + 'url(test.css)': 1, }, uniquenessRatio: 1, } @@ -476,6 +488,7 @@ test('finds @imports', () => { test('finds @charsets', () => { const fixture = ` @charset "UTF-8"; + @charset "utf-8"; @charset "UTF-16"; /* No prelude */ @@ -483,13 +496,13 @@ test('finds @charsets', () => { ` const actual = analyze(fixture).atrules.charset const expected = { - total: 2, + total: 3, totalUnique: 2, unique: { - '"UTF-8"': 1, - '"UTF-16"': 1, + '"utf-8"': 2, + '"utf-16"': 1, }, - uniquenessRatio: 2 / 2, + uniquenessRatio: 2 / 3, } expect(actual).toEqual(expected) diff --git a/src/index.ts b/src/index.ts index a3065fca..83a10868 100644 --- a/src/index.ts +++ b/src/index.ts @@ -224,10 +224,11 @@ function analyzeInternal(css: string, options: Options, useLo let atruleLoc = toLoc(node) atruleNesting.push(depth) uniqueAtruleNesting.p(depth, atruleLoc) - atrules.p(node.name?.toLowerCase()!, atruleLoc) + let normalized_name = node.name?.toLowerCase() ?? '' + atrules.p(normalized_name, atruleLoc) //#region @FONT-FACE - if (str_equals('font-face', node.name!)) { + if (normalized_name === 'font-face') { let descriptors = Object.create(null) if (useLocations) { fontfaces_with_loc.p(node.start, toLoc(node)) @@ -244,23 +245,22 @@ function analyzeInternal(css: string, options: Options, useLo //#endregion if (node.prelude === null || node.prelude === undefined) { - if (str_equals('layer', node.name!)) { + if (normalized_name === 'layer') { // @layer without a prelude is anonymous layers.p('', toLoc(node)) atRuleComplexities.push(2) } } else { - let name = node.name! let complexity = 1 // All the AtRules in here MUST have a prelude, so we can count their names - if (str_equals('media', name)) { + if (normalized_name === 'media') { medias.p(node.prelude.text, toLoc(node)) if (isMediaBrowserhack(node.prelude)) { mediaBrowserhacks.p(node.prelude.text, toLoc(node)) complexity++ } - } else if (str_equals('supports', name)) { + } else if (normalized_name === 'supports') { supports.p(node.prelude.text, toLoc(node)) let hack = isSupportsBrowserhack(node.prelude) @@ -268,8 +268,8 @@ function analyzeInternal(css: string, options: Options, useLo supportsBrowserhacks.p(hack, toLoc(node)) complexity++ } - } else if (endsWith('keyframes', name)) { - let prelude = `@${name} ${node.prelude.text}` + } else if (normalized_name.endsWith('keyframes')) { + let prelude = `@${normalized_name} ${node.prelude.text}` keyframes.p(prelude, toLoc(node)) if (node.is_vendor_prefixed) { @@ -279,11 +279,11 @@ function analyzeInternal(css: string, options: Options, useLo // Mark the depth at which we enter a keyframes atrule keyframesDepth = depth - } else if (str_equals('layer', name)) { + } else if (normalized_name === 'layer') { for (let layer of node.prelude.text.split(',').map((s: string) => s.trim())) { layers.p(layer, toLoc(node)) } - } else if (str_equals('import', name)) { + } else if (normalized_name === 'import') { imports.p(node.prelude.text, toLoc(node)) if (node.prelude.has_children) { @@ -295,17 +295,17 @@ function analyzeInternal(css: string, options: Options, useLo } } } - } else if (str_equals('container', name)) { + } else if (normalized_name === 'container') { containers.p(node.prelude.text, toLoc(node)) if (node.prelude.first_child?.type === CONTAINER_QUERY) { if (node.prelude.first_child.first_child?.type === IDENTIFIER) { containerNames.p(node.prelude.first_child.first_child.text, toLoc(node)) } } - } else if (str_equals('property', name)) { + } else if (normalized_name === 'property') { registeredProperties.p(node.prelude.text, toLoc(node)) - } else if (str_equals('charset', name)) { - charsets.p(node.prelude.text, toLoc(node)) + } else if (normalized_name === 'charset') { + charsets.p(node.prelude.text.toLowerCase(), toLoc(node)) } atRuleComplexities.push(complexity) From e2daedf7a22d4d8ef21cddc48b99828b690339e4 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sat, 7 Feb 2026 20:27:06 +0100 Subject: [PATCH 05/28] additional casing tests --- src/atrules/atrules.test.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/atrules/atrules.test.ts b/src/atrules/atrules.test.ts index 2899f50e..282d0082 100644 --- a/src/atrules/atrules.test.ts +++ b/src/atrules/atrules.test.ts @@ -518,18 +518,21 @@ test('finds @supports', () => { @supports (-webkit-appearance: none) {} } + @SUPPORTS (display: grid) {} + /* No prelude */ @supports {} ` const actual = analyze(fixture).atrules.supports - expect(actual.total).toEqual(4) - expect(actual.totalUnique).toEqual(3) - expect(actual.uniquenessRatio).toEqual(3 / 4) + expect(actual.total).toEqual(5) + expect(actual.totalUnique).toEqual(4) + expect(actual.uniquenessRatio).toEqual(4 / 5) expect(actual.unique).toEqual({ '(filter: blur(5px))': 1, '(display: table-cell) and (display: list-item)': 1, '(-webkit-appearance: none)': 2, + '(display: grid)': 1, }) }) @@ -537,7 +540,7 @@ test('finds @supports browserhacks', () => { const fixture = ` @supports (-webkit-appearance:none) {} @supports (-webkit-appearance: none) {} - @supports (-moz-appearance:meterbar) {} + @SUPPORTS (-moz-appearance:meterbar) {} @supports (-moz-appearance:meterbar) and (display:flex) {} @supports (-moz-appearance:meterbar) and (cursor:zoom-in) {} @supports (-moz-appearance:meterbar) and (background-attachment:local) {} @@ -577,13 +580,15 @@ test('finds @media', () => { @media (min-width: 0) {} } + @MEDIA all {} + /* No prelude */ @media {} ` const actual = analyze(fixture).atrules.media - expect(actual.total).toEqual(7) - expect(actual.totalUnique).toEqual(7) + expect(actual.total).toEqual(8) + expect(actual.totalUnique).toEqual(8) expect(actual.unique).toEqual({ screen: 1, 'screen and (min-width: 33em)': 1, @@ -592,6 +597,7 @@ test('finds @media', () => { 'screen or print': 1, 'all and (transform-3d), (-webkit-transform-3d)': 1, '(min-width: 0)': 1, + all: 1, }) expect(actual.uniquenessRatio).toEqual(1) }) From 71db811f8a919ecc309a4f15658665d01bb936ae Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sat, 7 Feb 2026 20:35:37 +0100 Subject: [PATCH 06/28] normalize `@media` browserhacks --- src/atrules/atrules.test.ts | 28 +++++++++----------------- src/atrules/atrules.ts | 40 ++++++++++++++++++++++++------------- src/index.ts | 5 +++-- 3 files changed, 38 insertions(+), 35 deletions(-) diff --git a/src/atrules/atrules.test.ts b/src/atrules/atrules.test.ts index 282d0082..d9714242 100644 --- a/src/atrules/atrules.test.ts +++ b/src/atrules/atrules.test.ts @@ -627,26 +627,16 @@ test('finds @media browserhacks', () => { const actual = result.atrules.media.browserhacks const expected = { total: 17, - totalUnique: 17, - uniquenessRatio: 1, + totalUnique: 7, + uniquenessRatio: 7 / 17, unique: { - '\\0 all': 1, - '\\0 screen': 1, - '\\0screen': 1, - 'screen\\9': 1, - '\\0screen\\,screen\\9': 1, - 'screen and (min-width:0\\0)': 1, - 'all and (-moz-images-in-menus:0) and (min-resolution: .001dpcm)': 1, - 'all and (-moz-images-in-menus:0)': 1, - 'screen and (-moz-images-in-menus:0)': 1, - 'screen and (min--moz-device-pixel-ratio:0)': 1, - 'all and (min--moz-device-pixel-ratio:0)': 1, - 'all and (min--moz-device-pixel-ratio:0) and (min-resolution: .001dpcm)': 1, - 'all and (min--moz-device-pixel-ratio:0) and (min-resolution: 3e1dpcm)': 1, - 'screen and (-ms-high-contrast: active), (-ms-high-contrast: none)': 1, - '(min-resolution: .001dpcm)': 1, - 'all and (-webkit-min-device-pixel-ratio:0) and (min-resolution: .001dpcm)': 1, - 'all and (-webkit-min-device-pixel-ratio:10000), not all and (-webkit-min-device-pixel-ratio:0)': 1, + '\\0': 5, + '\\9': 1, + '-moz-images-in-menus': 3, + 'min-resolution: .001dpcm': 1, + '-webkit-min-device-pixel-ratio': 2, + 'min--moz-device-pixel-ratio': 4, + '-ms-high-contrast': 1, }, } diff --git a/src/atrules/atrules.ts b/src/atrules/atrules.ts index da91e193..4288a9c0 100644 --- a/src/atrules/atrules.ts +++ b/src/atrules/atrules.ts @@ -45,15 +45,21 @@ export function isSupportsBrowserhack(node: CSSNode): false | string { * @param node - The Atrule CSSNode from Wallace parser * @returns true if the atrule is a browserhack */ -export function isMediaBrowserhack(node: CSSNode): boolean { - let isBrowserhack = false +export function isMediaBrowserhack(node: CSSNode): false | string { + let browserhack: string | false = false walk(node, function (n) { // Check MediaType nodes for \0 prefix or \9 suffix if (n.type === MEDIA_TYPE) { const text = n.text || '' - if (text.startsWith('\\0') || text.includes('\\9')) { - isBrowserhack = true + + if (text.startsWith('\\0')) { + browserhack = '\\0' + return BREAK + } + + if (text.includes('\\9')) { + browserhack = '\\9' return BREAK } } @@ -62,13 +68,19 @@ export function isMediaBrowserhack(node: CSSNode): boolean { if (n.type === MEDIA_FEATURE) { const name = n.name || '' + if (str_equals('-moz-images-in-menus', name)) { + browserhack = '-moz-images-in-menus' + return BREAK + } + + if (str_equals('min--moz-device-pixel-ratio', name)) { + browserhack = 'min--moz-device-pixel-ratio' + return BREAK + } + // Check for vendor-specific feature hacks - if ( - str_equals('-moz-images-in-menus', name) || - str_equals('min--moz-device-pixel-ratio', name) || - str_equals('-ms-high-contrast', name) - ) { - isBrowserhack = true + if (str_equals('-ms-high-contrast', name)) { + browserhack = '-ms-high-contrast' return BREAK } @@ -76,7 +88,7 @@ export function isMediaBrowserhack(node: CSSNode): boolean { if (str_equals('min-resolution', name) && n.has_children) { for (const child of n) { if (child.type === DIMENSION && child.value === 0.001 && str_equals('dpcm', child.unit || '')) { - isBrowserhack = true + browserhack = 'min-resolution: .001dpcm' return BREAK } } @@ -86,7 +98,7 @@ export function isMediaBrowserhack(node: CSSNode): boolean { if (str_equals('-webkit-min-device-pixel-ratio', name) && n.has_children) { for (const child of n) { if (child.type === NUMBER && (child.value === 0 || child.value === 10000)) { - isBrowserhack = true + browserhack = '-webkit-min-device-pixel-ratio' return BREAK } } @@ -96,7 +108,7 @@ export function isMediaBrowserhack(node: CSSNode): boolean { if (n.has_children) { for (const child of n) { if (child.type === IDENTIFIER && child.text === '\\0') { - isBrowserhack = true + browserhack = '\\0' return BREAK } } @@ -104,5 +116,5 @@ export function isMediaBrowserhack(node: CSSNode): boolean { } }) - return isBrowserhack + return browserhack } diff --git a/src/index.ts b/src/index.ts index 83a10868..33d0694e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -256,8 +256,9 @@ function analyzeInternal(css: string, options: Options, useLo // All the AtRules in here MUST have a prelude, so we can count their names if (normalized_name === 'media') { medias.p(node.prelude.text, toLoc(node)) - if (isMediaBrowserhack(node.prelude)) { - mediaBrowserhacks.p(node.prelude.text, toLoc(node)) + let hack = isMediaBrowserhack(node.prelude) + if (hack) { + mediaBrowserhacks.p(hack, toLoc(node)) complexity++ } } else if (normalized_name === 'supports') { From f63ecab806f2b8db921ffb4c18b5c61a6ae7f833 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sat, 7 Feb 2026 20:37:43 +0100 Subject: [PATCH 07/28] breaking: normalize media feature names --- src/atrules/atrules.test.ts | 7 ++++--- src/index.ts | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/atrules/atrules.test.ts b/src/atrules/atrules.test.ts index d9714242..df307af4 100644 --- a/src/atrules/atrules.test.ts +++ b/src/atrules/atrules.test.ts @@ -652,15 +652,16 @@ test('finds Media Features', () => { @media (prefers-color-scheme: dark) {} @media (prefers-reduced-motion: reduce) {} @media (prefers-contrast: more) {} + @MEDIA (MIN-WIDTH: 0) {} @media screen and (50px <= width <= 100px), (min-height: 100px) {} ` const actual = analyze(fixture).atrules.media.features const expected = { - total: 8, + total: 9, totalUnique: 8, unique: { - 'min-width': 1, + 'min-width': 2, 'max-width': 1, hover: 1, 'forced-colors': 1, @@ -669,7 +670,7 @@ test('finds Media Features', () => { 'prefers-contrast': 1, 'min-height': 1, }, - uniquenessRatio: 8 / 8, + uniquenessRatio: 8 / 9, } expect(actual).toEqual(expected) diff --git a/src/index.ts b/src/index.ts index 33d0694e..3507417d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -771,7 +771,9 @@ function analyzeInternal(css: string, options: Options, useLo } } } else if (node.type === MEDIA_FEATURE) { - mediaFeatures.p(node.name!, toLoc(node)) + if (node.name) { + mediaFeatures.p(node.name.toLowerCase(), toLoc(node)) + } return SKIP } }) From a0226b30ba313d9eaa425020533ebd12f8dd3772 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sat, 7 Feb 2026 20:47:28 +0100 Subject: [PATCH 08/28] breaking: normalize `@keyframes` names, independent of vendor prefix too --- src/atrules/atrules.test.ts | 25 +++++++++++++------------ src/index.ts | 6 +++--- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/atrules/atrules.test.ts b/src/atrules/atrules.test.ts index df307af4..4c5c5a8d 100644 --- a/src/atrules/atrules.test.ts +++ b/src/atrules/atrules.test.ts @@ -694,6 +694,7 @@ test('analyzes @keyframes', () => { @keyframes one {} @keyframes one {} @keyframes TWO {} + @KEYFRAMES three {} /* No prelude */ @keyframes {} @@ -702,29 +703,29 @@ test('analyzes @keyframes', () => { @-webkit-keyframes animation {} @-moz-keyframes animation {} @-o-keyframes animation {} + @-O-KEYFRAMES animation {} ` const actual = analyze(fixture).atrules.keyframes const expected = { - total: 6, - totalUnique: 5, + total: 8, + totalUnique: 4, unique: { - '@keyframes one': 2, - '@keyframes TWO': 1, - '@-webkit-keyframes animation': 1, - '@-moz-keyframes animation': 1, - '@-o-keyframes animation': 1, + one: 2, + TWO: 1, + three: 1, + animation: 4, }, - uniquenessRatio: 5 / 6, + uniquenessRatio: 4 / 8, prefixed: { - total: 3, + total: 4, totalUnique: 3, unique: { '@-webkit-keyframes animation': 1, '@-moz-keyframes animation': 1, - '@-o-keyframes animation': 1, + '@-o-keyframes animation': 2, }, - uniquenessRatio: 3 / 3, - ratio: 3 / 6, + uniquenessRatio: 3 / 4, + ratio: 4 / 8, }, } diff --git a/src/index.ts b/src/index.ts index 3507417d..0bcaa025 100644 --- a/src/index.ts +++ b/src/index.ts @@ -224,7 +224,7 @@ function analyzeInternal(css: string, options: Options, useLo let atruleLoc = toLoc(node) atruleNesting.push(depth) uniqueAtruleNesting.p(depth, atruleLoc) - let normalized_name = node.name?.toLowerCase() ?? '' + let normalized_name = basename(node.name?.toLowerCase() ?? '') atrules.p(normalized_name, atruleLoc) //#region @FONT-FACE @@ -270,11 +270,11 @@ function analyzeInternal(css: string, options: Options, useLo complexity++ } } else if (normalized_name.endsWith('keyframes')) { - let prelude = `@${normalized_name} ${node.prelude.text}` + let prelude = node.prelude.text keyframes.p(prelude, toLoc(node)) if (node.is_vendor_prefixed) { - prefixedKeyframes.p(prelude, toLoc(node)) + prefixedKeyframes.p(`@${node.name?.toLowerCase()} ${node.prelude.text}`, toLoc(node)) complexity++ } From 54f375d80058f7ca390fcf9fd35703ef37f9ed9d Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sat, 7 Feb 2026 20:53:45 +0100 Subject: [PATCH 09/28] one more test case for `@container` --- src/atrules/atrules.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/atrules/atrules.test.ts b/src/atrules/atrules.test.ts index 4c5c5a8d..a9713337 100644 --- a/src/atrules/atrules.test.ts +++ b/src/atrules/atrules.test.ts @@ -767,6 +767,8 @@ test('analyzes container queries', () => { h2 { font-size: 1.5em; } } + @CONTAINER (width > 40em) {} + /* Example 4 */ @container (--cards) { article { @@ -802,18 +804,18 @@ test('analyzes container queries', () => { const result = analyze(fixture) const actual = result.atrules.container const expected = { - total: 7, + total: 8, totalUnique: 7, unique: { '(inline-size > 45em)': 1, - '(width > 40em)': 1, + '(width > 40em)': 2, '(--cards)': 1, 'page-layout (block-size > 12em)': 1, 'component-library (inline-size > 30em)': 1, 'card (inline-size > 30em) and (--responsive = true)': 1, 'type(inline-size)': 1, }, - uniquenessRatio: 7 / 7, + uniquenessRatio: 7 / 8, names: { total: 3, totalUnique: 3, From 3fa3ea134407d7ca479e05d5532c22f6a6b6adee Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sat, 7 Feb 2026 22:41:33 +0100 Subject: [PATCH 10/28] breaking: normalize value keywords --- src/index.ts | 2 +- src/values/keywords.test.ts | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 0bcaa025..2c7e35c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -509,7 +509,7 @@ function analyzeInternal(css: string, options: Options, useLo // auto, inherit, initial, etc. if (keywords.has(text)) { - valueKeywords.p(text, valueLoc) + valueKeywords.p(text.toLowerCase(), valueLoc) valueComplexities.push(complexity) return } diff --git a/src/values/keywords.test.ts b/src/values/keywords.test.ts index ca7e148a..58b5d242 100644 --- a/src/values/keywords.test.ts +++ b/src/values/keywords.test.ts @@ -36,6 +36,27 @@ test('finds global keywords', () => { expect(actual).toEqual(expected) }) +test('normalizes keyword casing', () => { + const fixture = ` + test { + /* Global values */ + line-height: inherit; + line-height: INHERIT; + } + ` + const actual = analyze(fixture).values.keywords + const expected = { + total: 2, + totalUnique: 1, + unique: { + inherit: 2, + }, + uniquenessRatio: 1 / 2, + } + + expect(actual).toEqual(expected) +}) + test('finds global keywords in shorthands', () => { let fixture = ` a { From 717b3f414a54aa1059a049779da529f93ca297b4 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sat, 7 Feb 2026 22:43:38 +0100 Subject: [PATCH 11/28] normalize units --- src/index.ts | 2 +- src/values/units.test.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 2c7e35c0..bb2282e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -639,7 +639,7 @@ function analyzeInternal(css: string, options: Options, useLo walk(value, (valueNode) => { switch (valueNode.type) { case DIMENSION: { - let unit = valueNode.unit! + let unit = valueNode.unit?.toLowerCase() ?? '' let loc = toLoc(valueNode) units.push(unit, property, loc) return SKIP diff --git a/src/values/units.test.ts b/src/values/units.test.ts index 7fc5ff54..4eb5c66b 100644 --- a/src/values/units.test.ts +++ b/src/values/units.test.ts @@ -67,6 +67,21 @@ test('analyzes length units', () => { expect(actual).toEqual(expected) }) +test('normalizes units', () => { + let actual = analyze(` + a { + font-size: 10px; + width: 24PX; + } + `).values.units + + expect(actual.unique).toEqual({ + px: 2, + }) + expect(actual.total).toBe(2) + expect(actual.totalUnique).toBe(1) +}) + test('should not include browserhacks', () => { let actual = analyze(` a { From 504a504df0c78af241bc91c89318f8b0280f89c6 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sat, 7 Feb 2026 23:12:44 +0100 Subject: [PATCH 12/28] breaking: normalize properties --- src/index.ts | 10 +- src/properties/properties.test.ts | 153 ++++++++++++++++++++---------- 2 files changed, 111 insertions(+), 52 deletions(-) diff --git a/src/index.ts b/src/index.ts index bb2282e9..491a6484 100644 --- a/src/index.ts +++ b/src/index.ts @@ -473,7 +473,15 @@ function analyzeInternal(css: string, options: Options, useLo let propertyLoc = toLoc(node) propertyLoc.length = property.length - properties.p(property, propertyLoc) + if (!is_custom(property)) { + let base = basename(property).toLowerCase() + if (is_browserhack) { + base = base.slice(1) + } + properties.p(base, propertyLoc) + } else { + properties.p(property, propertyLoc) + } if (is_important) { importantDeclarations++ diff --git a/src/properties/properties.test.ts b/src/properties/properties.test.ts index 80c1f9be..f94b515b 100644 --- a/src/properties/properties.test.ts +++ b/src/properties/properties.test.ts @@ -23,6 +23,7 @@ test('calculates uniqueness', () => { const fixture = ` properties { margin: 0; + MARGIN: 0; --custom: 1; } @@ -34,17 +35,19 @@ test('calculates uniqueness', () => { ` const actual = analyze(fixture).properties const expected = { - margin: 1, + margin: 2, '--custom': 2, } expect(actual.totalUnique).toEqual(2) + expect(actual.total).toEqual(4) expect(actual.unique).toEqual(expected) - expect(actual.uniquenessRatio).toEqual(2 / 3) + expect(actual.uniquenessRatio).toEqual(2 / 4) }) -test('counts vendor prefixes', () => { - const fixture = ` +describe('vendor prefixes', () => { + test('counts vendor prefixes', () => { + const fixture = ` prefixed { border-radius: 2px; -webkit-border-radius: 2px; @@ -60,21 +63,30 @@ test('counts vendor prefixes', () => { } } ` - const actual = analyze(fixture).properties.prefixed - const expected = { - '-webkit-border-radius': 1, - '-khtml-border-radius': 1, - '-o-border-radius': 2, - } + const actual = analyze(fixture).properties.prefixed + const expected = { + '-webkit-border-radius': 1, + '-khtml-border-radius': 1, + '-o-border-radius': 2, + } - expect(actual.total).toEqual(4) - expect(actual.totalUnique).toEqual(3) - expect(actual.unique).toEqual(expected) - expect(actual.ratio).toEqual(4 / 5) + expect(actual.total).toEqual(4) + expect(actual.totalUnique).toEqual(3) + expect(actual.unique).toEqual(expected) + expect(actual.ratio).toEqual(4 / 5) + }) + + test('normalizes to basename', () => { + const fixture = `test { -webkit-line-clamp: 3; }` + const expected = { 'line-clamp': 1 } + const actual = analyze(fixture).properties.unique + expect(actual).toEqual(expected) + }) }) -test('counts browser hacks', () => { - const fixture = ` +describe('browserhacks', () => { + test('counts browser hacks', () => { + const fixture = ` hacks { margin: 0; *zoom: 1; @@ -91,49 +103,88 @@ test('counts browser hacks', () => { } } ` - const actual = analyze(fixture).properties.browserhacks - const expected = { - '*': 3, - _: 1, - } + const actual = analyze(fixture).properties.browserhacks + const expected = { + '*': 3, + _: 1, + } - expect(actual.total).toEqual(4) - expect(actual.totalUnique).toEqual(2) - expect(actual.unique).toEqual(expected) - expect(actual.ratio).toEqual(4 / 6) + expect(actual.total).toEqual(4) + expect(actual.totalUnique).toEqual(2) + expect(actual.unique).toEqual(expected) + expect(actual.ratio).toEqual(4 / 6) + }) + + test('normalizes browserhacks props to basename', () => { + const fixture = ` + hacks { + margin: 0; + *zoom: 1; + *margin: 0; + _property: 0; + --custom: 1; + } + ` + const actual = analyze(fixture).properties + const expected = { + margin: 2, + zoom: 1, + property: 1, + '--custom': 1, + } + + expect(actual.unique).toEqual(expected) + expect(actual.totalUnique).toEqual(4) + }) }) -test('counts custom properties', () => { - const fixture = ` - :root { - --yellow-400: yellow; - } +describe('custom properties', () => { + test('counts custom properties', () => { + const fixture = ` + :root { + --yellow-400: yellow; + } - custom { - margin: 0; - --yellow-400: yellow; - color: var(--yellow-400); - } + custom { + margin: 0; + --yellow-400: yellow; + color: var(--yellow-400); + } - @media (min-width: 0) { - @supports (-o-border-radius: 2px) { - custom2 { - --green-400: green; - color: var(--green-400); + @media (min-width: 0) { + @supports (-o-border-radius: 2px) { + custom2 { + --green-400: green; + color: var(--green-400); + } } } - } - ` - const actual = analyze(fixture).properties.custom - const expected = { - '--yellow-400': 2, - '--green-400': 1, - } + ` + const actual = analyze(fixture).properties.custom + const expected = { + '--yellow-400': 2, + '--green-400': 1, + } - expect(actual.total).toEqual(3) - expect(actual.totalUnique).toEqual(2) - expect(actual.unique).toEqual(expected) - expect(actual.ratio).toEqual(3 / 6) + expect(actual.total).toEqual(3) + expect(actual.totalUnique).toEqual(2) + expect(actual.unique).toEqual(expected) + expect(actual.ratio).toEqual(3 / 6) + }) + + test('normalizing properties does not lowercase custom properties', () => { + const fixture = ` + :root { + --testMe: 1; + } + ` + const actual = analyze(fixture).properties.custom + const expected = { + '--testMe': 1, + } + + expect(actual.unique).toEqual(expected) + }) }) describe('property complexity', () => { From 74b338efcfff338d69b96763262e2acfbc85718d Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sat, 7 Feb 2026 23:25:15 +0100 Subject: [PATCH 13/28] breaking: group and normalize value prefixes --- src/index.ts | 7 +++-- src/values/vendor-prefix.test.ts | 45 ++++++++++++++++++++++++-------- src/values/vendor-prefix.ts | 10 +++---- 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/src/index.ts b/src/index.ts index 491a6484..f0446a03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -524,8 +524,11 @@ function analyzeInternal(css: string, options: Options, useLo //#region VALUE COMPLEXITY // i.e. `background-image: -webkit-linear-gradient()` - if (isValuePrefixed(value)) { - vendorPrefixedValues.p(value.text, valueLoc) + let prefixes = isValuePrefixed(value) + if (prefixes !== false) { + for (let prefix of prefixes) { + vendorPrefixedValues.p(prefix.toLowerCase(), valueLoc) + } complexity++ } diff --git a/src/values/vendor-prefix.test.ts b/src/values/vendor-prefix.test.ts index 758970af..c8500e11 100644 --- a/src/values/vendor-prefix.test.ts +++ b/src/values/vendor-prefix.test.ts @@ -34,11 +34,11 @@ test('finds simple prefixes', () => { unique: { '-moz-max-content': 1, '-webkit-max-content': 1, - '0 0 0 3px -moz-mac-focusring': 1, + '-moz-mac-focusring': 1, '-webkit-sticky': 2, - '-webkit-transform 0.3s ease-out': 1, - '-moz-transform 0.3s ease-out': 1, - '-o-transform 0.3s ease-out': 2, + '-webkit-transform': 1, + '-moz-transform': 1, + '-o-transform': 2, }, uniquenessRatio: 7 / 9, } @@ -46,6 +46,26 @@ test('finds simple prefixes', () => { expect(actual).toEqual(expected) }) +test('normalizes prefixes', () => { + const fixture = ` + value-vendor-prefix-simple { + width: -moz-max-content; + WIDTH: -MOZ-MAX-CONTENT; + } + ` + const actual = analyze(fixture).values.prefixes + const expected = { + total: 2, + totalUnique: 1, + unique: { + '-moz-max-content': 2, + }, + uniquenessRatio: 1 / 2, + } + + expect(actual).toEqual(expected) +}) + test('finds nested prefixes', () => { const fixture = ` value-vendor-prefix-nested { @@ -61,12 +81,15 @@ test('finds nested prefixes', () => { ` const actual = analyze(fixture).values.prefixes const expected = { - total: 3, - totalUnique: 3, + total: 6, + totalUnique: 6, unique: { - '-khtml-linear-gradient(90deg, red, green)': 1, - 'red,\n -webkit-linear-gradient(transparent, transparent),\n -moz-linear-gradient(transparent, transparent),\n -ms-linear-gradient(transparent, transparent),\n -o-linear-gradient(transparent, transparent)': 1, - 'repeat(3, max(-webkit-max-content, 100vw))': 1, + '-khtml-linear-gradient': 1, + '-webkit-linear-gradient': 1, + '-moz-linear-gradient': 1, + '-ms-linear-gradient': 1, + '-o-linear-gradient': 1, + '-webkit-max-content': 1, }, uniquenessRatio: 1, } @@ -74,7 +97,7 @@ test('finds nested prefixes', () => { expect(actual).toEqual(expected) }) -test.skip('finds DEEPLY nested prefixes', () => { +test('finds DEEPLY nested prefixes', () => { const fixture = ` value-vendor-prefix-deeply-nested { width: var(--test, -webkit-max-content); @@ -85,7 +108,7 @@ test.skip('finds DEEPLY nested prefixes', () => { total: 1, totalUnique: 1, unique: { - 'var(--test, -webkit-max-content)': 1, + '-webkit-max-content': 1, }, uniquenessRatio: 1, } diff --git a/src/values/vendor-prefix.ts b/src/values/vendor-prefix.ts index 0d00ec66..72675313 100644 --- a/src/values/vendor-prefix.ts +++ b/src/values/vendor-prefix.ts @@ -1,14 +1,14 @@ import { type CSSNode, walk, BREAK } from '@projectwallace/css-parser' -export function isValuePrefixed(node: CSSNode): boolean { - let isPrefixed = false +export function isValuePrefixed(node: CSSNode): false | string[] { + let prefixes: string[] = [] walk(node, function (child) { if (child.is_vendor_prefixed) { - isPrefixed = true - return BREAK + // .name in case of Identifier or Function, .text as fallback + prefixes.push(child.name || child.text) } }) - return isPrefixed + return prefixes.length > 0 ? prefixes : false } From a19d6fbb7dd9d0418d9e630ef5b4bf946bc9337c Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sat, 7 Feb 2026 23:38:04 +0100 Subject: [PATCH 14/28] normalize font sizes --- src/index.ts | 9 +++++-- src/values/font-sizes.test.ts | 44 +++++++++++++++++++++++++++++++---- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index f0446a03..b78592c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -585,7 +585,7 @@ function analyzeInternal(css: string, options: Options, useLo } if (font_size) { - fontSizes.p(font_size, valueLoc) + fontSizes.p(font_size.toLowerCase(), valueLoc) } if (line_height) { @@ -596,7 +596,12 @@ function analyzeInternal(css: string, options: Options, useLo // units, colors, and font families in var() fallbacks } else if (isProperty('font-size', property)) { if (!SYSTEM_FONTS.has(text)) { - fontSizes.p(text, valueLoc) + let normalized = text.toLowerCase() + if (normalized.includes('var(')) { + fontSizes.p(text, valueLoc) + } else { + fontSizes.p(normalized, valueLoc) + } } } else if (isProperty('font-family', property)) { if (!SYSTEM_FONTS.has(text)) { diff --git a/src/values/font-sizes.test.ts b/src/values/font-sizes.test.ts index 05c45405..943d76a6 100644 --- a/src/values/font-sizes.test.ts +++ b/src/values/font-sizes.test.ts @@ -1,4 +1,4 @@ -import { test } from 'vitest' +import { describe, test } from 'vitest' import { expect } from 'vitest' import { analyze } from '../index.js' @@ -45,8 +45,8 @@ test('extracts the `font` shorthand', () => { font: normal normal 11px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; font: 11px Consolas, "Liberation Mono", Menlo, Courier, monospace; font: 0/0 a; /* As generated by some css minifiers */ - font: 10PX sans-serif; - font: 12px var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); /* from github.com */ + font: 12px sans-serif; + font: 13px var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); /* from github.com */ /* Unrelated */ color: brown; @@ -65,14 +65,50 @@ test('extracts the `font` shorthand', () => { '1.3em': 1, '2em': 1, '11px': 2, - '10PX': 1, '12px': 1, + '13px': 1, }, uniquenessRatio: 9 / 13, } expect(actual).toEqual(expected) }) +describe('normalize', () => { + test('normalizes font sizes', () => { + const fixture = `test { + font-size: 10PX; + font-size: 10px; + }` + const actual = analyze(fixture).values.fontSizes.unique + const expected = { + '10px': 2, + } + expect(actual).toEqual(expected) + }) + + test('normalizes font shorthand', () => { + const fixture = `test { + font: 10PX/1 sans-serif; + }` + const actual = analyze(fixture).values.fontSizes.unique + const expected = { + '10px': 1, + } + expect(actual).toEqual(expected) + }) + + test('leaves var() intact', () => { + const fixture = `test { + font-size: var(--mySize); + }` + const actual = analyze(fixture).values.fontSizes.unique + const expected = { + 'var(--mySize)': 1, + } + expect(actual).toEqual(expected) + }) +}) + test('handles system fonts', () => { // Source: https://drafts.csswg.org/css-fonts-3/#font-prop const fixture = ` From 1a85ee0013be9e24480792d2915bc5904c032edd Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sat, 7 Feb 2026 23:51:38 +0100 Subject: [PATCH 15/28] normalize line heights --- src/index.ts | 9 +++++++-- src/values/line-heights.test.ts | 25 ++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index b78592c8..a19cc302 100644 --- a/src/index.ts +++ b/src/index.ts @@ -589,7 +589,7 @@ function analyzeInternal(css: string, options: Options, useLo } if (line_height) { - lineHeights.p(line_height, valueLoc) + lineHeights.p(line_height.toLowerCase(), valueLoc) } } // Don't return SKIP here - let walker continue to find @@ -609,7 +609,12 @@ function analyzeInternal(css: string, options: Options, useLo } return SKIP // to prevent finding color false positives (Black as font family name is not a color) } else if (isProperty('line-height', property)) { - lineHeights.p(text, valueLoc) + let normalized = text.toLowerCase() + if (normalized.includes('var(')) { + lineHeights.p(text, valueLoc) + } else { + lineHeights.p(normalized, valueLoc) + } } else if (isProperty('transition', property) || isProperty('animation', property)) { analyzeAnimation(value.children, function (item: { type: string; value: CSSNode }) { if (item.type === 'fn') { diff --git a/src/values/line-heights.test.ts b/src/values/line-heights.test.ts index 4f2f63bc..80a7c0b4 100644 --- a/src/values/line-heights.test.ts +++ b/src/values/line-heights.test.ts @@ -1,4 +1,4 @@ -import { test } from 'vitest' +import { describe, test } from 'vitest' import { expect } from 'vitest' import { analyze } from '../index.js' @@ -64,6 +64,29 @@ test('extracts the `font` shorthand', () => { expect(actual).toEqual(expected) }) +describe('normalizing', () => { + test('line-height', () => { + const actual = analyze(`test { line-height: 1EM }`).values.lineHeights.unique + expect(actual).toEqual({ '1em': 1 }) + }) + + test('font shorthand', () => { + const actual = analyze(`test { font: 10PX/1EM serif }`).values.lineHeights.unique + expect(actual).toEqual({ '1em': 1 }) + }) + + test('leaves var() intact', () => { + const actual = analyze(`test { + line-height: var(--lineHeight1); + line-height: VAR(--lineHeight2); + }`).values.lineHeights.unique + expect(actual).toEqual({ + 'var(--lineHeight1)': 1, + 'VAR(--lineHeight2)': 1, + }) + }) +}) + test('handles system fonts', () => { // Source: https://drafts.csswg.org/css-fonts-3/#font-prop const fixture = ` From 0239c30868546646a69b43769f36c727aa294d98 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sat, 7 Feb 2026 23:53:59 +0100 Subject: [PATCH 16/28] fix keyword casing in font shorthand --- src/index.ts | 2 +- src/values/keywords.test.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index a19cc302..2cd0a6da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -571,7 +571,7 @@ function analyzeInternal(css: string, options: Options, useLo if (!SYSTEM_FONTS.has(text)) { let result = destructure(value, function (item) { if (item.type === 'keyword') { - valueKeywords.p(item.value, valueLoc) + valueKeywords.p(item.value.toLowerCase(), valueLoc) } }) diff --git a/src/values/keywords.test.ts b/src/values/keywords.test.ts index 58b5d242..827b51ed 100644 --- a/src/values/keywords.test.ts +++ b/src/values/keywords.test.ts @@ -42,16 +42,17 @@ test('normalizes keyword casing', () => { /* Global values */ line-height: inherit; line-height: INHERIT; + font: 10px/12px INHERIT; } ` const actual = analyze(fixture).values.keywords const expected = { - total: 2, + total: 3, totalUnique: 1, unique: { - inherit: 2, + inherit: 3, }, - uniquenessRatio: 1 / 2, + uniquenessRatio: 1 / 3, } expect(actual).toEqual(expected) From 428d75d6452636426efb3ef39a990298c6908932 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 8 Feb 2026 00:20:18 +0100 Subject: [PATCH 17/28] normalize resets and durations --- src/index.ts | 54 ++++--- src/values/animations.test.ts | 260 ++++++++++++++++++---------------- src/values/resets.test.ts | 12 +- 3 files changed, 186 insertions(+), 140 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2cd0a6da..ac086f6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -469,16 +469,17 @@ function analyzeInternal(css: string, options: Options, useLo let { is_important, property, is_browserhack, is_vendor_prefixed } = node if (!property) return + let normalizedProperty = property let propertyLoc = toLoc(node) propertyLoc.length = property.length if (!is_custom(property)) { - let base = basename(property).toLowerCase() + normalizedProperty = basename(property).toLowerCase() if (is_browserhack) { - base = base.slice(1) + normalizedProperty = normalizedProperty.slice(1) } - properties.p(base, propertyLoc) + properties.p(normalizedProperty, propertyLoc) } else { properties.p(property, propertyLoc) } @@ -546,23 +547,33 @@ function analyzeInternal(css: string, options: Options, useLo // Process properties first that don't have colors, // so we can avoid further walking them; if ( - isProperty('margin', property) || - isProperty('margin-block', property) || - isProperty('margin-inline', property) || - isProperty('margin-top', property) || - isProperty('margin-right', property) || - isProperty('margin-bottom', property) || - isProperty('margin-left', property) || - isProperty('padding', property) || - isProperty('padding-block', property) || - isProperty('padding-inline', property) || - isProperty('padding-top', property) || - isProperty('padding-right', property) || - isProperty('padding-bottom', property) || - isProperty('padding-left', property) + new Set([ + 'margin', + 'margin-block', + 'margin-inline', + 'margin-top', + 'margin-block-start', + 'margin-block-end', + 'margin-inline-end', + 'margin-inline-end', + 'margin-right', + 'margin-bottom', + 'margin-left', + 'padding', + 'padding-block', + 'padding-inline', + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'padding-block-start', + 'padding-block-end', + 'padding-inline-start', + 'padding-inline-end', + ]).has(normalizedProperty) ) { if (isValueReset(value)) { - resets.p(property, valueLoc) + resets.p(normalizedProperty, valueLoc) } } else if (isProperty('z-index', property)) { zindex.p(text, valueLoc) @@ -629,7 +640,12 @@ function analyzeInternal(css: string, options: Options, useLo } else if (isProperty('animation-duration', property) || isProperty('transition-duration', property)) { for (let child of value.children) { if (child.type !== OPERATOR) { - durations.p(child.text, valueLoc) + let text = child.text + if (/var\(/i.test(text)) { + durations.p(text, valueLoc) + } else { + durations.p(text.toLowerCase(), valueLoc) + } } } } else if (isProperty('transition-timing-function', property) || isProperty('animation-timing-function', property)) { diff --git a/src/values/animations.test.ts b/src/values/animations.test.ts index dbd72acb..68996e7f 100644 --- a/src/values/animations.test.ts +++ b/src/values/animations.test.ts @@ -3,7 +3,7 @@ import { expect } from 'vitest' import { analyze } from '../index.js' test('finds simple durations', () => { - const fixture = ` + const fixture = ` durations { animation-duration: 1s; animation-duration: 2ms; @@ -11,43 +11,65 @@ test('finds simple durations', () => { --my-transition-duration: 0s; } ` - const actual = analyze(fixture).values.animations.durations - const expected = { - total: 3, - totalUnique: 3, - unique: { - '1s': 1, - '2ms': 1, - '300ms': 1, - }, - uniquenessRatio: 3 / 3 - } - expect(actual).toEqual(expected) + const actual = analyze(fixture).values.animations.durations + const expected = { + total: 3, + totalUnique: 3, + unique: { + '1s': 1, + '2ms': 1, + '300ms': 1, + }, + uniquenessRatio: 3 / 3, + } + expect(actual).toEqual(expected) }) test('finds duration lists', () => { - const fixture = ` + const fixture = ` durations { animation-duration: 1s, 1s; transition-duration: 300ms, 400ms; } ` - const actual = analyze(fixture).values.animations.durations - const expected = { - total: 4, - totalUnique: 3, - unique: { - '1s': 2, - '300ms': 1, - '400ms': 1, - }, - uniquenessRatio: 3 / 4 - } - expect(actual).toEqual(expected) + const actual = analyze(fixture).values.animations.durations + const expected = { + total: 4, + totalUnique: 3, + unique: { + '1s': 2, + '300ms': 1, + '400ms': 1, + }, + uniquenessRatio: 3 / 4, + } + expect(actual).toEqual(expected) +}) + +test('normalizes durations', () => { + const fixture = ` + durations { + animation-duration: 1S, 1s; + transition-duration: 300ms, 300MS; + animation-duration: var(--myDur); + } + ` + const actual = analyze(fixture).values.animations.durations + const expected = { + total: 5, + totalUnique: 3, + unique: { + '1s': 2, + '300ms': 2, + 'var(--myDur)': 1, + }, + uniquenessRatio: 3 / 5, + } + expect(actual).toEqual(expected) }) test('finds simple timing functions', () => { - const fixture = ` + const fixture = ` timings { animation-timing-function: linear; animation-timing-function: cubic-bezier(0, 1, 0, 1); @@ -59,42 +81,42 @@ test('finds simple timing functions', () => { --my-transition-timing-function: invalid; } ` - const actual = analyze(fixture).values.animations.timingFunctions - const expected = { - total: 4, - totalUnique: 3, - unique: { - 'linear': 1, - 'cubic-bezier(0, 1, 0, 1)': 2, - 'steps(3)': 1, - }, - uniquenessRatio: 3 / 4 - } - expect(actual).toEqual(expected) + const actual = analyze(fixture).values.animations.timingFunctions + const expected = { + total: 4, + totalUnique: 3, + unique: { + linear: 1, + 'cubic-bezier(0, 1, 0, 1)': 2, + 'steps(3)': 1, + }, + uniquenessRatio: 3 / 4, + } + expect(actual).toEqual(expected) }) test('finds timing functions in value lists', () => { - const fixture = ` + const fixture = ` timings { animation-timing-function: ease, step-start, cubic-bezier(0.1, 0.7, 1, 0.1); } ` - const actual = analyze(fixture).values.animations.timingFunctions - const expected = { - total: 3, - totalUnique: 3, - unique: { - 'ease': 1, - 'step-start': 1, - 'cubic-bezier(0.1, 0.7, 1, 0.1)': 1, - }, - uniquenessRatio: 1 - } - expect(actual).toEqual(expected) + const actual = analyze(fixture).values.animations.timingFunctions + const expected = { + total: 3, + totalUnique: 3, + unique: { + ease: 1, + 'step-start': 1, + 'cubic-bezier(0.1, 0.7, 1, 0.1)': 1, + }, + uniquenessRatio: 1, + } + expect(actual).toEqual(expected) }) test('finds shorthand durations', () => { - const fixture = ` + const fixture = ` durations { animation: 1s ANIMATION_NAME linear; animation: 2s ANIMATION_NAME cubic-bezier(0,1,0,1); @@ -107,24 +129,24 @@ test('finds shorthand durations', () => { --my-transition: invalid; } ` - const actual = analyze(fixture).values.animations.durations - const expected = { - total: 5, - totalUnique: 5, - unique: { - '1s': 1, - '2s': 1, - '3s': 1, - '4s': 1, - '5s': 1, - }, - uniquenessRatio: 5 / 5 - } - expect(actual).toEqual(expected) + const actual = analyze(fixture).values.animations.durations + const expected = { + total: 5, + totalUnique: 5, + unique: { + '1s': 1, + '2s': 1, + '3s': 1, + '4s': 1, + '5s': 1, + }, + uniquenessRatio: 5 / 5, + } + expect(actual).toEqual(expected) }) test('finds shorthand timing functions', () => { - const fixture = ` + const fixture = ` durations { animation: 1s ANIMATION_NAME linear; animation: 2s ANIMATION_NAME cubic-bezier(0,1,0,1); @@ -139,22 +161,22 @@ test('finds shorthand timing functions', () => { --my-transition: invalid; } ` - const actual = analyze(fixture).values.animations.timingFunctions - const expected = { - total: 5, - totalUnique: 3, - unique: { - 'linear': 2, - 'cubic-bezier(0,1,0,1)': 2, - 'Cubic-Bezier(0,1,0,1)': 1, - }, - uniquenessRatio: 3 / 5 - } - expect(actual).toEqual(expected) + const actual = analyze(fixture).values.animations.timingFunctions + const expected = { + total: 5, + totalUnique: 3, + unique: { + linear: 2, + 'cubic-bezier(0,1,0,1)': 2, + 'Cubic-Bezier(0,1,0,1)': 1, + }, + uniquenessRatio: 3 / 5, + } + expect(actual).toEqual(expected) }) test('analyzes animations/transitions with value lists', () => { - const fixture = ` + const fixture = ` multi-value { animation: 1s ANIMATION_NAME linear, 2s ANIMATION_NAME linear; animation: 3s ANIMATION_NAME ease 3ms, 4s ANIMATION_NAME ease-in-out 4ms; @@ -164,41 +186,41 @@ test('analyzes animations/transitions with value lists', () => { transition: all 11s, font-size 12s 12ms, line-height 13ms, border 0.0014s; } ` - const actual = analyze(fixture).values.animations - const expected = { - durations: { - total: 14, - totalUnique: 14, - unique: { - '1s': 1, - '2s': 1, - '3s': 1, - '4s': 1, - '5s': 1, - '6s': 1, - '7s': 1, - '8s': 1, - '9s': 1, - '10s': 1, - '11s': 1, - '12s': 1, - '13ms': 1, - '0.0014s': 1, - }, - uniquenessRatio: 14 / 14 - }, - timingFunctions: { - total: 8, - totalUnique: 5, - unique: { - 'linear': 3, - 'ease': 2, - 'ease-in-out': 1, - 'steps(4, step-end)': 1, - 'steps(2)': 1, - }, - uniquenessRatio: 5 / 8 - } - } - expect(actual).toEqual(expected) -}) \ No newline at end of file + const actual = analyze(fixture).values.animations + const expected = { + durations: { + total: 14, + totalUnique: 14, + unique: { + '1s': 1, + '2s': 1, + '3s': 1, + '4s': 1, + '5s': 1, + '6s': 1, + '7s': 1, + '8s': 1, + '9s': 1, + '10s': 1, + '11s': 1, + '12s': 1, + '13ms': 1, + '0.0014s': 1, + }, + uniquenessRatio: 14 / 14, + }, + timingFunctions: { + total: 8, + totalUnique: 5, + unique: { + linear: 3, + ease: 2, + 'ease-in-out': 1, + 'steps(4, step-end)': 1, + 'steps(2)': 1, + }, + uniquenessRatio: 5 / 8, + }, + } + expect(actual).toEqual(expected) +}) diff --git a/src/values/resets.test.ts b/src/values/resets.test.ts index d5c7e140..595bae53 100644 --- a/src/values/resets.test.ts +++ b/src/values/resets.test.ts @@ -47,7 +47,7 @@ test('accepts weird casing', () => { MARGIN: 0; }`) let resets = actual.values.resets - expect(resets.unique).toEqual({ MARGIN: 1 }) + expect(resets.unique).toEqual({ margin: 1 }) }) test('accepts vendor prefixes', () => { @@ -55,7 +55,15 @@ test('accepts vendor prefixes', () => { -webkit-margin: 0; }`) let resets = actual.values.resets - expect(resets.unique).toEqual({ '-webkit-margin': 1 }) + expect(resets.unique).toEqual({ margin: 1 }) +}) + +test('accepts browserhacks', () => { + let actual = analyze(`t { + *margin: 0; + }`) + let resets = actual.values.resets + expect(resets.unique).toEqual({ margin: 1 }) }) // Test all properties From 0a2b1021dd2a4f5be3159836a942b48925880411 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 8 Feb 2026 11:47:46 +0100 Subject: [PATCH 18/28] rework spacing resets const --- src/index.ts | 43 ++------------------------------ src/properties/property-utils.ts | 38 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 41 deletions(-) diff --git a/src/index.ts b/src/index.ts index ac086f6d..66ba77c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,6 @@ import { type CSSNode, is_custom, SKIP, - str_equals, str_starts_with, walk, parse, @@ -39,23 +38,10 @@ import { endsWith, unquote } from './string-utils.js' import { isProperty } from './properties/property-utils.js' import { getEmbedType } from './stylesheet/stylesheet.js' import { isIe9Hack } from './values/browserhacks.js' -import { basename } from './properties/property-utils.js' -import { KeywordSet } from './keyword-set.js' +import { basename, SPACING_RESET_PROPERTIES, border_radius_properties } from './properties/property-utils.js' export type Specificity = [number, number, number] -let border_radius_properties = new KeywordSet([ - 'border-radius', - 'border-top-left-radius', - 'border-top-right-radius', - 'border-bottom-right-radius', - 'border-bottom-left-radius', - 'border-start-start-radius', - 'border-start-end-radius', - 'border-end-end-radius', - 'border-end-start-radius', -]) - function ratio(part: number, total: number): number { if (total === 0) return 0 return part / total @@ -546,32 +532,7 @@ function analyzeInternal(css: string, options: Options, useLo // Process properties first that don't have colors, // so we can avoid further walking them; - if ( - new Set([ - 'margin', - 'margin-block', - 'margin-inline', - 'margin-top', - 'margin-block-start', - 'margin-block-end', - 'margin-inline-end', - 'margin-inline-end', - 'margin-right', - 'margin-bottom', - 'margin-left', - 'padding', - 'padding-block', - 'padding-inline', - 'padding-top', - 'padding-right', - 'padding-bottom', - 'padding-left', - 'padding-block-start', - 'padding-block-end', - 'padding-inline-start', - 'padding-inline-end', - ]).has(normalizedProperty) - ) { + if (SPACING_RESET_PROPERTIES.has(normalizedProperty)) { if (isValueReset(value)) { resets.p(normalizedProperty, valueLoc) } diff --git a/src/properties/property-utils.ts b/src/properties/property-utils.ts index df24af9e..d4af0bdb 100644 --- a/src/properties/property-utils.ts +++ b/src/properties/property-utils.ts @@ -1,6 +1,44 @@ +import { KeywordSet } from '../keyword-set.js' import { endsWith } from '../string-utils.js' import { is_custom, is_vendor_prefixed } from '@projectwallace/css-parser' +export const SPACING_RESET_PROPERTIES = new Set([ + 'margin', + 'margin-block', + 'margin-inline', + 'margin-top', + 'margin-block-start', + 'margin-block-end', + 'margin-inline-end', + 'margin-inline-end', + 'margin-right', + 'margin-bottom', + 'margin-left', + 'padding', + 'padding-block', + 'padding-inline', + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'padding-block-start', + 'padding-block-end', + 'padding-inline-start', + 'padding-inline-end', +]) + +export const border_radius_properties = new KeywordSet([ + 'border-radius', + 'border-top-left-radius', + 'border-top-right-radius', + 'border-bottom-right-radius', + 'border-bottom-left-radius', + 'border-start-start-radius', + 'border-start-end-radius', + 'border-end-end-radius', + 'border-end-start-radius', +]) + /** * @see https://github.com/csstree/csstree/blob/master/lib/utils/names.js#L69 */ From 7f6e14ad520723746af483f67264cdcbdfde7b36 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 8 Feb 2026 11:54:15 +0100 Subject: [PATCH 19/28] chore: move selector a11y tests to standalone file --- src/index.ts | 2 +- src/selectors/a11y.test.ts | 30 ++++++++++++++++++++++++++++++ src/selectors/selectors.test.ts | 28 ---------------------------- src/selectors/utils.ts | 5 +++-- 4 files changed, 34 insertions(+), 31 deletions(-) create mode 100644 src/selectors/a11y.test.ts diff --git a/src/index.ts b/src/index.ts index 66ba77c8..30ca5819 100644 --- a/src/index.ts +++ b/src/index.ts @@ -379,7 +379,7 @@ function analyzeInternal(css: string, options: Options, useLo } let pseudos = hasPseudoClass(node) - if (pseudos !== false) { + if (pseudos) { for (let pseudo of pseudos) { pseudoClasses.p(pseudo, loc) } diff --git a/src/selectors/a11y.test.ts b/src/selectors/a11y.test.ts new file mode 100644 index 00000000..9ee1c880 --- /dev/null +++ b/src/selectors/a11y.test.ts @@ -0,0 +1,30 @@ +import { test, expect } from 'vitest' +import { analyze } from '../index.js' + +test('counts Accessibility selectors', () => { + const fixture = ` + [aria-hidden], + img[role="presentation"], + .selector:not([role="tablist"]), + body.intent-mouse路[role=tabpanel][tabindex="0"]:focus, + img[loading="lazy"], + [hidden] {} + + /* Note: img[loading="lazy"] and [hidden] are false positives for accessibility */ + ` + const actual = analyze(fixture).selectors.accessibility + const expected = { + total: 4, + totalUnique: 4, + unique: { + '[aria-hidden]': 1, + 'img[role="presentation"]': 1, + '.selector:not([role="tablist"])': 1, + 'body.intent-mouse路[role=tabpanel][tabindex="0"]:focus': 1, + }, + uniquenessRatio: 1 / 1, + ratio: 4 / 6, + } + + expect(actual).toEqual(expected) +}) diff --git a/src/selectors/selectors.test.ts b/src/selectors/selectors.test.ts index 19dfc210..e987e999 100644 --- a/src/selectors/selectors.test.ts +++ b/src/selectors/selectors.test.ts @@ -258,34 +258,6 @@ test('counts ID selectors', () => { expect(actual).toEqual(expected) }) -test('counts Accessibility selectors', () => { - const fixture = ` - [aria-hidden], - img[role="presentation"], - .selector:not([role="tablist"]), - body.intent-mouse路[role=tabpanel][tabindex="0"]:focus, - img[loading="lazy"], - [hidden] {} - - /* Note: img[loading="lazy"] and [hidden] are false positives for accessibility */ - ` - const actual = analyze(fixture).selectors.accessibility - const expected = { - total: 4, - totalUnique: 4, - unique: { - '[aria-hidden]': 1, - 'img[role="presentation"]': 1, - '.selector:not([role="tablist"])': 1, - 'body.intent-mouse路[role=tabpanel][tabindex="0"]:focus': 1, - }, - uniquenessRatio: 1 / 1, - ratio: 4 / 6, - } - - expect(actual).toEqual(expected) -}) - test('handles emoji selectors', () => { const fixture = ` .馃挬 {} diff --git a/src/selectors/utils.ts b/src/selectors/utils.ts index 2e87f2e9..4576e50a 100644 --- a/src/selectors/utils.ts +++ b/src/selectors/utils.ts @@ -34,8 +34,9 @@ export function isPrefixed(selector: CSSNode): boolean { /** * Check if a Wallace selector is an accessibility selector (has aria-* or role attribute) */ -export function isAccessibility(selector: CSSNode): boolean { +export function isAccessibility(selector: CSSNode): false | string[] { let isA11y = false + let a11y: string[] = [] walk(selector, function (node) { if (node.type === ATTRIBUTE_SELECTOR) { @@ -59,7 +60,7 @@ export function isAccessibility(selector: CSSNode): boolean { } }) - return isA11y + return isA11y ? a11y : false } /** From 37f5102d066dccf50b53619ebc1b86de12b0b784 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 8 Feb 2026 15:07:03 +0100 Subject: [PATCH 20/28] breaking: normalize a11y selectors --- src/index.ts | 6 +-- src/selectors/a11y.test.ts | 107 +++++++++++++++++++++++++++++++++---- src/selectors/utils.ts | 36 ++++++------- 3 files changed, 115 insertions(+), 34 deletions(-) diff --git a/src/index.ts b/src/index.ts index 30ca5819..ee7c49f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -374,9 +374,9 @@ function analyzeInternal(css: string, options: Options, useLo } // Check for accessibility selectors - if (isAccessibility(node)) { - a11y.p(node.text, loc) - } + isAccessibility(node, (a11y_selector) => { + a11y.p(a11y_selector, loc) + }) let pseudos = hasPseudoClass(node) if (pseudos) { diff --git a/src/selectors/a11y.test.ts b/src/selectors/a11y.test.ts index 9ee1c880..acaf2dc9 100644 --- a/src/selectors/a11y.test.ts +++ b/src/selectors/a11y.test.ts @@ -3,28 +3,113 @@ import { analyze } from '../index.js' test('counts Accessibility selectors', () => { const fixture = ` + .test, [aria-hidden], img[role="presentation"], - .selector:not([role="tablist"]), - body.intent-mouse路[role=tabpanel][tabindex="0"]:focus, - img[loading="lazy"], - [hidden] {} + body.intent-mouse [role=tabpanel][tabindex="0"]:focus {} + ` + const actual = analyze(fixture).selectors.accessibility + const expected = { + total: 3, + totalUnique: 3, + unique: { + '[aria-hidden]': 1, + '[role="presentation"]': 1, + '[role="tabpanel"]': 1, + }, + uniquenessRatio: 3 / 3, + ratio: 3 / 4, + } + + expect(actual).toEqual(expected) +}) - /* Note: img[loading="lazy"] and [hidden] are false positives for accessibility */ +test('finds selectors within pseudo classes', () => { + const fixture = ` + .selector:not([role="tablist"]) {} + ` + const actual = analyze(fixture).selectors.accessibility + expect(actual.unique).toEqual({ '[role="tablist"]': 1 }) +}) + +test('groups by attribute & value', () => { + const fixture = ` + [aria-hidden], + [ARIA-HIDDEN], + [ROLE=tablist], + [ROLE="tablist"], + body.intent-mouse [role=tablist][tabindex="0"]:focus {} + ` + const actual = analyze(fixture).selectors.accessibility + const expected = { + total: 5, + totalUnique: 2, + unique: { + '[aria-hidden]': 2, + '[role="tablist"]': 3, + }, + uniquenessRatio: 2 / 5, + ratio: 5 / 5, + } + + expect(actual).toEqual(expected) +}) + +test('handles attribute selector flags', () => { + const fixture = ` + [aria-hidden i], + [ARIA-HIDDEN="false" I], + [ROLE="tablist" s], + [ROLE="tablist" S] {} ` const actual = analyze(fixture).selectors.accessibility const expected = { total: 4, - totalUnique: 4, + totalUnique: 3, + unique: { + '[aria-hidden]': 1, + '[aria-hidden="false"]': 1, + '[role="tablist"]': 2, + }, + uniquenessRatio: 3 / 4, + ratio: 4 / 4, + } + + expect(actual).toEqual(expected) +}) + +test('normalizes attribute name', () => { + const fixture = ` + [ARIA-HIDDEN], + img[ROLE="presentation"], + img[ROLE="PRESENTATION"], + .selector:not([ROLE="tablist"]), + body.intent-mouse [role=tabpanel][tabindex="0"]:focus {} + ` + const actual = analyze(fixture).selectors.accessibility + const expected = { + total: 5, + totalUnique: 5, unique: { '[aria-hidden]': 1, - 'img[role="presentation"]': 1, - '.selector:not([role="tablist"])': 1, - 'body.intent-mouse路[role=tabpanel][tabindex="0"]:focus': 1, + '[role="presentation"]': 1, + '[role="PRESENTATION"]': 1, // makes a difference for CSS, but not for accessibility tree + '[role="tablist"]': 1, + '[role="tabpanel"]': 1, }, - uniquenessRatio: 1 / 1, - ratio: 4 / 6, + uniquenessRatio: 5 / 5, + ratio: 5 / 5, } expect(actual).toEqual(expected) }) + +test('does not report false positives', () => { + const fixture = ` + img[loading="lazy"], + [hidden] {} + ` + const actual = analyze(fixture).selectors.accessibility + expect(actual.total).toEqual(0) + expect(actual.unique).toEqual({}) +}) diff --git a/src/selectors/utils.ts b/src/selectors/utils.ts index 4576e50a..a198c426 100644 --- a/src/selectors/utils.ts +++ b/src/selectors/utils.ts @@ -12,7 +12,10 @@ import { SELECTOR, COMBINATOR, NTH_SELECTOR, + str_equals, + str_starts_with, } from '@projectwallace/css-parser' +import { unquote } from '../string-utils.js' const PSEUDO_FUNCTIONS = new KeywordSet(['nth-child', 'where', 'not', 'is', 'has', 'nth-last-child', 'matches', '-webkit-any', '-moz-any']) @@ -34,33 +37,26 @@ export function isPrefixed(selector: CSSNode): boolean { /** * Check if a Wallace selector is an accessibility selector (has aria-* or role attribute) */ -export function isAccessibility(selector: CSSNode): false | string[] { - let isA11y = false - let a11y: string[] = [] +export function isAccessibility(selector: CSSNode, on_selector: (a11y_selector: string) => void): void { + function normalize(node: CSSNode) { + let clone = node.clone() + // We're intentionally not adding attr_flags here because they don't matter for normalization + // Also not lowercasing node.value because that DOES matter for CSS + if (clone.value) { + return '[' + clone.name?.toLowerCase() + clone.attr_operator + '"' + unquote(clone.value.toString()) + '"' + ']' + } else { + return '[' + clone.name?.toLowerCase() + ']' + } + } walk(selector, function (node) { if (node.type === ATTRIBUTE_SELECTOR) { const name = node.name || '' - if (name === 'role' || name.startsWith('aria-')) { - isA11y = true - return BREAK - } - } - // Test for [aria-] or [role] inside :is()/:where() and friends - else if (node.type === PSEUDO_CLASS_SELECTOR && PSEUDO_FUNCTIONS.has(node.name || '')) { - // Check if any child selectors are accessibility selectors - if (node.has_children) { - for (const child of node) { - if (child.type === SELECTOR && isAccessibility(child)) { - isA11y = true - return BREAK - } - } + if (str_equals('role', name) || str_starts_with(name, 'aria-')) { + on_selector(normalize(node)) } } }) - - return isA11y ? a11y : false } /** From fe4255bf603333f633e65b6c6fb5b00f6f7b563c Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 8 Feb 2026 15:15:16 +0100 Subject: [PATCH 21/28] breaking: normalize selector vendor prefixes --- src/index.ts | 6 +++--- src/selectors/selectors.test.ts | 13 +++++++------ src/selectors/utils.ts | 15 ++++++++------- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/index.ts b/src/index.ts index ee7c49f5..0d51dfcf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -369,9 +369,9 @@ function analyzeInternal(css: string, options: Options, useLo selectorComplexities.push(complexity) uniqueSelectorComplexities.p(complexity, loc) - if (isPrefixed(node)) { - prefixedSelectors.p(node.text, loc) - } + isPrefixed(node, (prefix) => { + prefixedSelectors.p(prefix.toLowerCase(), loc) + }) // Check for accessibility selectors isAccessibility(node, (a11y_selector) => { diff --git a/src/selectors/selectors.test.ts b/src/selectors/selectors.test.ts index e987e999..5fccc31f 100644 --- a/src/selectors/selectors.test.ts +++ b/src/selectors/selectors.test.ts @@ -368,6 +368,7 @@ test('analyzes vendor prefixed selectors', () => { input[type=text]:-moz-placeholder { color: green; } + input:-MOZ-PLACEHOLDER {} no-prefix, fake-webkit, @@ -376,15 +377,15 @@ test('analyzes vendor prefixed selectors', () => { :-moz-any(header, footer) {} `).selectors.prefixed - expect(actual.total).toBe(6) + expect(actual.total).toBe(7) expect(actual.totalUnique).toBe(6) expect(actual.unique).toEqual({ - 'input[type=text]::-webkit-input-placeholder': 1, - 'input[type=text]:-ms-input-placeholder': 1, + ':-moz-any': 1, + ':-moz-placeholder': 2, + ':-ms-input-placeholder': 1, + '::-webkit-input-placeholder': 1, '::-webkit-scrollbar': 1, - ':-moz-any(header, footer)': 1, - 'input[type=text]::-moz-placeholder': 1, - 'input[type=text]:-moz-placeholder': 1, + '::-moz-placeholder': 1, }) }) diff --git a/src/selectors/utils.ts b/src/selectors/utils.ts index a198c426..8cfdf8a7 100644 --- a/src/selectors/utils.ts +++ b/src/selectors/utils.ts @@ -19,19 +19,20 @@ import { unquote } from '../string-utils.js' const PSEUDO_FUNCTIONS = new KeywordSet(['nth-child', 'where', 'not', 'is', 'has', 'nth-last-child', 'matches', '-webkit-any', '-moz-any']) -export function isPrefixed(selector: CSSNode): boolean { - let isPrefixed = false - +export function isPrefixed(selector: CSSNode, on_selector: (prefix: string) => void): void { walk(selector, function (node) { if (node.type === PSEUDO_ELEMENT_SELECTOR || node.type === PSEUDO_CLASS_SELECTOR || node.type === TYPE_SELECTOR) { if (node.is_vendor_prefixed) { - isPrefixed = true - return BREAK + let prefix = '' + if (node.type === PSEUDO_CLASS_SELECTOR) { + prefix = ':' + } else if (node.type === PSEUDO_ELEMENT_SELECTOR) { + prefix = '::' + } + on_selector(prefix + (node.name || node.text)) } } }) - - return isPrefixed } /** From 6f662425c9a599af2b9d8d418732fe9851f207b1 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 8 Feb 2026 15:35:12 +0100 Subject: [PATCH 22/28] normalize pseudo classes --- src/index.ts | 9 +++------ src/selectors/pseudos.test.ts | 19 +++++++++++++++++++ src/selectors/utils.ts | 15 ++------------- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0d51dfcf..c70d7523 100644 --- a/src/index.ts +++ b/src/index.ts @@ -378,12 +378,9 @@ function analyzeInternal(css: string, options: Options, useLo a11y.p(a11y_selector, loc) }) - let pseudos = hasPseudoClass(node) - if (pseudos) { - for (let pseudo of pseudos) { - pseudoClasses.p(pseudo, loc) - } - } + hasPseudoClass(node, (pseudo) => { + pseudoClasses.p(pseudo.toLowerCase(), loc) + }) getCombinators(node, function onCombinator(combinator) { let name = combinator.name.trim() === '' ? ' ' : combinator.name diff --git a/src/selectors/pseudos.test.ts b/src/selectors/pseudos.test.ts index aaf3067d..f7bd3082 100644 --- a/src/selectors/pseudos.test.ts +++ b/src/selectors/pseudos.test.ts @@ -25,6 +25,25 @@ test('calculates pseudo classes', () => { expect(actual).toEqual(expected) }) +test('normalizes pseudo name', () => { + const fixture = ` + a:hover, + A:HOVER {} + ` + let actual = analyze(fixture).selectors.pseudoClasses + expect(actual.unique).toEqual({ hover: 2 }) + expect(actual.totalUnique).toBe(1) +}) + +test('can find multiple pseudos in one selector', () => { + const fixture = ` + main:has(a) a:hover {} + ` + let actual = analyze(fixture).selectors.pseudoClasses + expect(actual.unique).toEqual({ has: 1, hover: 1 }) + expect(actual.totalUnique).toBe(2) +}) + test('logs the whole parent selector when using locations', () => { let actual = analyze( ` diff --git a/src/selectors/utils.ts b/src/selectors/utils.ts index 8cfdf8a7..8b46c998 100644 --- a/src/selectors/utils.ts +++ b/src/selectors/utils.ts @@ -60,23 +60,12 @@ export function isAccessibility(selector: CSSNode, on_selector: (a11y_selector: }) } -/** - * @returns {string[] | false} The pseudo-class name if it exists, otherwise false - */ -export function hasPseudoClass(selector: CSSNode): string[] | false { - let pseudos: string[] = [] - +export function hasPseudoClass(selector: CSSNode, on_selector: (selector: string) => void): void { walk(selector, function (node) { if (node.type === PSEUDO_CLASS_SELECTOR && node.name) { - pseudos.push(node.name) + on_selector(node.name) } }) - - if (pseudos.length === 0) { - return false - } - - return pseudos } /** From cd44bd3b71c7097e2341bb10dae3f287a7998ceb Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 8 Feb 2026 15:41:09 +0100 Subject: [PATCH 23/28] chore: convert value prefix analysis to callback structure --- src/index.ts | 9 +++------ src/values/vendor-prefix.ts | 8 ++------ 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index c70d7523..489b8baf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -508,13 +508,10 @@ function analyzeInternal(css: string, options: Options, useLo //#region VALUE COMPLEXITY // i.e. `background-image: -webkit-linear-gradient()` - let prefixes = isValuePrefixed(value) - if (prefixes !== false) { - for (let prefix of prefixes) { - vendorPrefixedValues.p(prefix.toLowerCase(), valueLoc) - } + isValuePrefixed(value, (prefixed) => { + vendorPrefixedValues.p(prefixed.toLowerCase(), valueLoc) complexity++ - } + }) // i.e. `property: value\9` if (isIe9Hack(value)) { diff --git a/src/values/vendor-prefix.ts b/src/values/vendor-prefix.ts index 72675313..27af6ef2 100644 --- a/src/values/vendor-prefix.ts +++ b/src/values/vendor-prefix.ts @@ -1,14 +1,10 @@ import { type CSSNode, walk, BREAK } from '@projectwallace/css-parser' -export function isValuePrefixed(node: CSSNode): false | string[] { - let prefixes: string[] = [] - +export function isValuePrefixed(node: CSSNode, on_value: (value: string) => void): void { walk(node, function (child) { if (child.is_vendor_prefixed) { // .name in case of Identifier or Function, .text as fallback - prefixes.push(child.name || child.text) + on_value(child.name || child.text) } }) - - return prefixes.length > 0 ? prefixes : false } From ed262ce46b885ce1b3119da5c6147a2df6e7daf2 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 8 Feb 2026 16:02:18 +0100 Subject: [PATCH 24/28] chore: improve basename() --- src/index.ts | 43 +++++++++++---------------- src/properties/property-utils.test.ts | 13 -------- src/properties/property-utils.ts | 33 ++++++++------------ src/values/resets.test.ts | 22 ++++++++------ 4 files changed, 42 insertions(+), 69 deletions(-) delete mode 100644 src/properties/property-utils.test.ts diff --git a/src/index.ts b/src/index.ts index 489b8baf..e8764d2e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,7 +35,6 @@ import { ContextCollection } from './context-collection.js' import { Collection, type Location } from './collection.js' import { AggregateCollection } from './aggregate-collection.js' import { endsWith, unquote } from './string-utils.js' -import { isProperty } from './properties/property-utils.js' import { getEmbedType } from './stylesheet/stylesheet.js' import { isIe9Hack } from './values/browserhacks.js' import { basename, SPACING_RESET_PROPERTIES, border_radius_properties } from './properties/property-utils.js' @@ -210,7 +209,7 @@ function analyzeInternal(css: string, options: Options, useLo let atruleLoc = toLoc(node) atruleNesting.push(depth) uniqueAtruleNesting.p(depth, atruleLoc) - let normalized_name = basename(node.name?.toLowerCase() ?? '') + let normalized_name = basename(node.name ?? '') atrules.p(normalized_name, atruleLoc) //#region @FONT-FACE @@ -452,20 +451,12 @@ function analyzeInternal(css: string, options: Options, useLo let { is_important, property, is_browserhack, is_vendor_prefixed } = node if (!property) return - let normalizedProperty = property let propertyLoc = toLoc(node) propertyLoc.length = property.length + let normalizedProperty = basename(property) - if (!is_custom(property)) { - normalizedProperty = basename(property).toLowerCase() - if (is_browserhack) { - normalizedProperty = normalizedProperty.slice(1) - } - properties.p(normalizedProperty, propertyLoc) - } else { - properties.p(property, propertyLoc) - } + properties.p(normalizedProperty, propertyLoc) if (is_important) { importantDeclarations++ @@ -530,10 +521,10 @@ function analyzeInternal(css: string, options: Options, useLo if (isValueReset(value)) { resets.p(normalizedProperty, valueLoc) } - } else if (isProperty('z-index', property)) { + } else if (normalizedProperty === 'z-index') { zindex.p(text, valueLoc) return SKIP - } else if (isProperty('font', property)) { + } else if (normalizedProperty === 'font') { if (!SYSTEM_FONTS.has(text)) { let result = destructure(value, function (item) { if (item.type === 'keyword') { @@ -560,7 +551,7 @@ function analyzeInternal(css: string, options: Options, useLo } // Don't return SKIP here - let walker continue to find // units, colors, and font families in var() fallbacks - } else if (isProperty('font-size', property)) { + } else if (normalizedProperty === 'font-size') { if (!SYSTEM_FONTS.has(text)) { let normalized = text.toLowerCase() if (normalized.includes('var(')) { @@ -569,19 +560,19 @@ function analyzeInternal(css: string, options: Options, useLo fontSizes.p(normalized, valueLoc) } } - } else if (isProperty('font-family', property)) { + } else if (normalizedProperty === 'font-family') { if (!SYSTEM_FONTS.has(text)) { fontFamilies.p(text, valueLoc) } return SKIP // to prevent finding color false positives (Black as font family name is not a color) - } else if (isProperty('line-height', property)) { + } else if (normalizedProperty === 'line-height') { let normalized = text.toLowerCase() if (normalized.includes('var(')) { lineHeights.p(text, valueLoc) } else { lineHeights.p(normalized, valueLoc) } - } else if (isProperty('transition', property) || isProperty('animation', property)) { + } else if (normalizedProperty === 'transition' || normalizedProperty === 'animation') { analyzeAnimation(value.children, function (item: { type: string; value: CSSNode }) { if (item.type === 'fn') { timingFunctions.p(item.value.text, valueLoc) @@ -592,7 +583,7 @@ function analyzeInternal(css: string, options: Options, useLo } }) return SKIP - } else if (isProperty('animation-duration', property) || isProperty('transition-duration', property)) { + } else if (normalizedProperty === 'animation-duration' || normalizedProperty === 'transition-duration') { for (let child of value.children) { if (child.type !== OPERATOR) { let text = child.text @@ -603,25 +594,25 @@ function analyzeInternal(css: string, options: Options, useLo } } } - } else if (isProperty('transition-timing-function', property) || isProperty('animation-timing-function', property)) { + } else if (normalizedProperty === 'transition-timing-function' || normalizedProperty === 'animation-timing-function') { for (let child of value.children) { if (child.type !== OPERATOR) { timingFunctions.p(child.text, valueLoc) } } - } else if (isProperty('container-name', property)) { + } else if (normalizedProperty === 'container-name') { containerNames.p(text, valueLoc) - } else if (isProperty('container', property)) { + } else if (normalizedProperty === 'container') { // The first identifier in the `container` shorthand is the container name // Example: container: my-layout / inline-size; if (value.first_child?.type === IDENTIFIER) { containerNames.p(value.first_child.text, valueLoc) } - } else if (border_radius_properties.has(basename(property))) { + } else if (border_radius_properties.has(normalizedProperty)) { borderRadiuses.push(text, property, valueLoc) - } else if (isProperty('text-shadow', property)) { + } else if (normalizedProperty === 'text-shadow') { textShadows.p(text, valueLoc) - } else if (isProperty('box-shadow', property)) { + } else if (normalizedProperty === 'box-shadow') { boxShadows.p(text, valueLoc) } @@ -668,7 +659,7 @@ function analyzeInternal(css: string, options: Options, useLo // Skip all identifier processing for font properties to avoid: // 1. False positives for colors (e.g., "Black" as a font family vs. "black" the color) // 2. Duplicate keywords (already extracted by destructure function) - if (isProperty('font', property) || isProperty('font-family', property)) { + if (normalizedProperty === 'font' || normalizedProperty === 'font-family') { return SKIP } diff --git a/src/properties/property-utils.test.ts b/src/properties/property-utils.test.ts deleted file mode 100644 index b8ec1797..00000000 --- a/src/properties/property-utils.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { test, expect } from 'vitest' -import { isProperty } from './property-utils.js' - -test('isProperty', () => { - expect(isProperty('animation', 'animation')).toEqual(true) - expect(isProperty('animation', 'ANIMATION')).toEqual(true) - expect(isProperty('animation', '-webkit-animation')).toEqual(true) - expect(isProperty('font', '_font')).toEqual(true) - expect(isProperty('width', '*width')).toEqual(true) - - expect(isProperty('animation', '--animation')).toEqual(false) - expect(isProperty('property', '--Property')).toEqual(false) -}) diff --git a/src/properties/property-utils.ts b/src/properties/property-utils.ts index d4af0bdb..a1cfafca 100644 --- a/src/properties/property-utils.ts +++ b/src/properties/property-utils.ts @@ -1,5 +1,4 @@ import { KeywordSet } from '../keyword-set.js' -import { endsWith } from '../string-utils.js' import { is_custom, is_vendor_prefixed } from '@projectwallace/css-parser' export const SPACING_RESET_PROPERTIES = new Set([ @@ -59,29 +58,21 @@ export function isHack(property: string): boolean { } /** - * A check to verify that a propery is `basename` or a prefixed - * version of that, but never a custom property that accidentally - * ends with the same substring. - * - * @example - * isProperty('animation', 'animation') // true - * isProperty('animation', '-webkit-animation') // true - * isProperty('animation', '--my-animation') // false - * - * @returns True if `property` equals `basename` without prefix - */ -export function isProperty(basename: string, property: string): boolean { - if (is_custom(property)) return false - return endsWith(basename, property) -} - -/** - * Get the basename for a property with a vendor prefix + * Get the normalized basename for a property with a vendor prefix * @returns The property name without vendor prefix */ export function basename(property: string): string { + if (is_custom(property)) { + return property + } + if (is_vendor_prefixed(property)) { - return property.slice(property.indexOf('-', 2) + 1) + return property.slice(property.indexOf('-', 2) + 1).toLowerCase() + } + + if (isHack(property)) { + return property.slice(1).toLowerCase() } - return property + + return property.toLowerCase() } diff --git a/src/values/resets.test.ts b/src/values/resets.test.ts index 595bae53..0f4da9e5 100644 --- a/src/values/resets.test.ts +++ b/src/values/resets.test.ts @@ -45,25 +45,29 @@ test('crazy notations', () => { test('accepts weird casing', () => { let actual = analyze(`t { MARGIN: 0; - }`) - let resets = actual.values.resets - expect(resets.unique).toEqual({ margin: 1 }) + }`).values.resets + expect(actual.unique).toEqual({ margin: 1 }) }) test('accepts vendor prefixes', () => { let actual = analyze(`t { -webkit-margin: 0; - }`) - let resets = actual.values.resets - expect(resets.unique).toEqual({ margin: 1 }) + }`).values.resets + expect(actual.unique).toEqual({ margin: 1 }) }) test('accepts browserhacks', () => { let actual = analyze(`t { *margin: 0; - }`) - let resets = actual.values.resets - expect(resets.unique).toEqual({ margin: 1 }) + }`).values.resets + expect(actual.unique).toEqual({ margin: 1 }) +}) + +test('does not report false positive for custom property', () => { + let actual = analyze(`t { + --my-margin: 0; + }`).values.resets + expect(actual.unique).toEqual({}) }) // Test all properties From bd9443f3d0b011f360993a92affa542667091eef Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 8 Feb 2026 16:09:37 +0100 Subject: [PATCH 25/28] normalize dirations + fns --- src/index.ts | 8 ++++---- src/values/animations.test.ts | 22 ++++++++++++---------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index e8764d2e..9d83a52f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -573,13 +573,13 @@ function analyzeInternal(css: string, options: Options, useLo lineHeights.p(normalized, valueLoc) } } else if (normalizedProperty === 'transition' || normalizedProperty === 'animation') { - analyzeAnimation(value.children, function (item: { type: string; value: CSSNode }) { + analyzeAnimation(value.children, function (item) { if (item.type === 'fn') { - timingFunctions.p(item.value.text, valueLoc) + timingFunctions.p(item.value.text.toLowerCase(), valueLoc) } else if (item.type === 'duration') { - durations.p(item.value.text, valueLoc) + durations.p(item.value.text.toLowerCase(), valueLoc) } else if (item.type === 'keyword') { - valueKeywords.p(item.value.text, valueLoc) + valueKeywords.p(item.value.text.toLowerCase(), valueLoc) } }) return SKIP diff --git a/src/values/animations.test.ts b/src/values/animations.test.ts index 68996e7f..8d39142e 100644 --- a/src/values/animations.test.ts +++ b/src/values/animations.test.ts @@ -120,10 +120,12 @@ test('finds shorthand durations', () => { durations { animation: 1s ANIMATION_NAME linear; animation: 2s ANIMATION_NAME cubic-bezier(0,1,0,1); + animation: 2S ANIMATION_NAME cubic-bezier(0,1,0,1); transition: all 3s; transition: all 4s cubic-bezier(0,1,0,1); transition: all 5s linear 5000ms; + transition: all 5S linear 5000ms; --my-animation: invalid; --my-transition: invalid; @@ -131,16 +133,16 @@ test('finds shorthand durations', () => { ` const actual = analyze(fixture).values.animations.durations const expected = { - total: 5, + total: 7, totalUnique: 5, unique: { '1s': 1, - '2s': 1, + '2s': 2, '3s': 1, '4s': 1, - '5s': 1, + '5s': 2, }, - uniquenessRatio: 5 / 5, + uniquenessRatio: 5 / 7, } expect(actual).toEqual(expected) }) @@ -149,6 +151,7 @@ test('finds shorthand timing functions', () => { const fixture = ` durations { animation: 1s ANIMATION_NAME linear; + animation: 1s ANIMATION_NAME LINEAR; animation: 2s ANIMATION_NAME cubic-bezier(0,1,0,1); transition: all 3s; @@ -163,14 +166,13 @@ test('finds shorthand timing functions', () => { ` const actual = analyze(fixture).values.animations.timingFunctions const expected = { - total: 5, - totalUnique: 3, + total: 6, + totalUnique: 2, unique: { - linear: 2, - 'cubic-bezier(0,1,0,1)': 2, - 'Cubic-Bezier(0,1,0,1)': 1, + linear: 3, + 'cubic-bezier(0,1,0,1)': 3, }, - uniquenessRatio: 3 / 5, + uniquenessRatio: 2 / 6, } expect(actual).toEqual(expected) }) From 56d9fa5b05200a29f364d341cb89fb53d0f212ac Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 8 Feb 2026 16:18:54 +0100 Subject: [PATCH 26/28] normalize colors --- src/index.ts | 13 +++--- src/values/colors.test.ts | 90 +++++++++++++++++++-------------------- 2 files changed, 51 insertions(+), 52 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9d83a52f..a65c03e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -633,7 +633,7 @@ function analyzeInternal(css: string, options: Options, useLo if (!hashText || !hashText.startsWith('#')) { return SKIP } - let hashValue = hashText + let hashValue = hashText.toLowerCase() // If the full value has an IE9 hack, append it to the hash if (valueHasIe9Hack && !hashValue.endsWith('\\9')) { @@ -664,7 +664,7 @@ function analyzeInternal(css: string, options: Options, useLo } if (keywords.has(identifierText)) { - valueKeywords.p(identifierText, identifierLoc) + valueKeywords.p(identifierText.toLowerCase(), identifierLoc) } // Bail out if it can't be a color name @@ -677,21 +677,22 @@ function analyzeInternal(css: string, options: Options, useLo // A keyword is most likely to be 'transparent' or 'currentColor' if (colorKeywords.has(identifierText)) { - colors.push(identifierText, property, identifierLoc) - colorFormats.p(identifierText.toLowerCase(), identifierLoc) + let colorKeyword = identifierText.toLowerCase() + colors.push(colorKeyword, property, identifierLoc) + colorFormats.p(colorKeyword, identifierLoc) return } // Or it can be a named color if (namedColors.has(identifierText)) { - colors.push(identifierText, property, identifierLoc) + colors.push(identifierText.toLowerCase(), property, identifierLoc) colorFormats.p('named', identifierLoc) return } // Or it can be a system color if (systemColors.has(identifierText)) { - colors.push(identifierText, property, identifierLoc) + colors.push(identifierText.toLowerCase(), property, identifierLoc) colorFormats.p('system', identifierLoc) return } diff --git a/src/values/colors.test.ts b/src/values/colors.test.ts index 1ac3d1f2..5545f3ac 100644 --- a/src/values/colors.test.ts +++ b/src/values/colors.test.ts @@ -48,7 +48,7 @@ test('finds hex colors', () => { '#555': 1, '#666': 1, '#777': 1, - '#AaBbCc': 1, + '#aabbcc': 1, }, uniquenessRatio: 9 / 10, itemsPerContext: { @@ -100,7 +100,7 @@ test('finds hex colors', () => { totalUnique: 1, uniquenessRatio: 1, unique: { - '#AaBbCc': 1, + '#aabbcc': 1, }, }, }, @@ -693,7 +693,7 @@ test('finds color keywords', () => { totalUnique: 4, unique: { tomato: 1, - Aqua: 1, + aqua: 1, whitesmoke: 1, purple: 2, }, @@ -711,7 +711,7 @@ test('finds color keywords', () => { total: 1, totalUnique: 1, unique: { - Aqua: 1, + aqua: 1, }, uniquenessRatio: 1, }, @@ -905,24 +905,22 @@ test('ignores CSS keywords', () => { const actual = analyze(fixture).values.colors const expected = { total: 4, - totalUnique: 4, + totalUnique: 3, unique: { - currentColor: 1, - currentcolor: 1, + currentcolor: 2, transparent: 1, 'rgb(0, 0, 0)': 1, }, - uniquenessRatio: 4 / 4, + uniquenessRatio: 3 / 4, itemsPerContext: { color: { total: 3, - totalUnique: 3, + totalUnique: 2, unique: { - currentColor: 1, - currentcolor: 1, + currentcolor: 2, transparent: 1, }, - uniquenessRatio: 3 / 3, + uniquenessRatio: 2 / 3, }, background: { total: 1, @@ -981,23 +979,23 @@ test('finds System Colors', () => { total: 17, totalUnique: 17, unique: { - Canvas: 1, - CanvasText: 1, - Linktext: 1, - VisitedText: 1, - ActiveText: 1, - ButtonFace: 1, - ButtonText: 1, - ButtonBorder: 1, - Field: 1, - FieldText: 1, - Highlight: 1, - HighlightText: 1, - SelectedItem: 1, - SelectedItemText: 1, - Mark: 1, - MarkText: 1, - GrayText: 1, + canvas: 1, + canvastext: 1, + linktext: 1, + visitedtext: 1, + activetext: 1, + buttonface: 1, + buttontext: 1, + buttonborder: 1, + field: 1, + fieldtext: 1, + highlight: 1, + highlighttext: 1, + selecteditem: 1, + selecteditemtext: 1, + mark: 1, + marktext: 1, + graytext: 1, }, uniquenessRatio: 17 / 17, itemsPerContext: { @@ -1005,23 +1003,23 @@ test('finds System Colors', () => { total: 17, totalUnique: 17, unique: { - Canvas: 1, - CanvasText: 1, - Linktext: 1, - VisitedText: 1, - ActiveText: 1, - ButtonFace: 1, - ButtonText: 1, - ButtonBorder: 1, - Field: 1, - FieldText: 1, - Highlight: 1, - HighlightText: 1, - SelectedItem: 1, - SelectedItemText: 1, - Mark: 1, - MarkText: 1, - GrayText: 1, + canvas: 1, + canvastext: 1, + linktext: 1, + visitedtext: 1, + activetext: 1, + buttonface: 1, + buttontext: 1, + buttonborder: 1, + field: 1, + fieldtext: 1, + highlight: 1, + highlighttext: 1, + selecteditem: 1, + selecteditemtext: 1, + mark: 1, + marktext: 1, + graytext: 1, }, uniquenessRatio: 19 / 19, }, From 5014031139f99dd60c5a2eff6214a5aa62cb29ec Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 8 Feb 2026 16:19:30 +0100 Subject: [PATCH 27/28] fix lint --- src/selectors/utils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/selectors/utils.ts b/src/selectors/utils.ts index 8b46c998..25c012a8 100644 --- a/src/selectors/utils.ts +++ b/src/selectors/utils.ts @@ -45,9 +45,8 @@ export function isAccessibility(selector: CSSNode, on_selector: (a11y_selector: // Also not lowercasing node.value because that DOES matter for CSS if (clone.value) { return '[' + clone.name?.toLowerCase() + clone.attr_operator + '"' + unquote(clone.value.toString()) + '"' + ']' - } else { - return '[' + clone.name?.toLowerCase() + ']' } + return '[' + clone.name?.toLowerCase() + ']' } walk(selector, function (node) { From c52fefcbd1df7a02b381085e08d16f5496af4cab Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 8 Feb 2026 16:31:19 +0100 Subject: [PATCH 28/28] more callbacks --- src/atrules/atrules.ts | 33 ++++++++++++--------------------- src/index.ts | 10 ++++------ 2 files changed, 16 insertions(+), 27 deletions(-) diff --git a/src/atrules/atrules.ts b/src/atrules/atrules.ts index 4288a9c0..558f7923 100644 --- a/src/atrules/atrules.ts +++ b/src/atrules/atrules.ts @@ -14,11 +14,8 @@ import { /** * Check if an @supports atRule is a browserhack (Wallace parser version) * @param node - The Atrule CSSNode from Wallace parser - * @returns true if the atrule is a browserhack */ -export function isSupportsBrowserhack(node: CSSNode): false | string { - let browserhack: false | string = false - +export function isSupportsBrowserhack(node: CSSNode, on_hack: (hack: string) => void): void { walk(node, function (n) { // Check SupportsQuery nodes for browserhack patterns if (n.type === SUPPORTS_QUERY) { @@ -27,17 +24,15 @@ export function isSupportsBrowserhack(node: CSSNode): false | string { // Check for known browserhack patterns if (normalizedPrelude.includes('-webkit-appearance:none')) { - browserhack = '-webkit-appearance: none' + on_hack('-webkit-appearance: none') return BREAK } if (normalizedPrelude.includes('-moz-appearance:meterbar')) { - browserhack = '-moz-appearance: meterbar' + on_hack('-moz-appearance: meterbar') return BREAK } } }) - - return browserhack } /** @@ -45,21 +40,19 @@ export function isSupportsBrowserhack(node: CSSNode): false | string { * @param node - The Atrule CSSNode from Wallace parser * @returns true if the atrule is a browserhack */ -export function isMediaBrowserhack(node: CSSNode): false | string { - let browserhack: string | false = false - +export function isMediaBrowserhack(node: CSSNode, on_hack: (hack: string) => void): void { walk(node, function (n) { // Check MediaType nodes for \0 prefix or \9 suffix if (n.type === MEDIA_TYPE) { const text = n.text || '' if (text.startsWith('\\0')) { - browserhack = '\\0' + on_hack('\\0') return BREAK } if (text.includes('\\9')) { - browserhack = '\\9' + on_hack('\\9') return BREAK } } @@ -69,18 +62,18 @@ export function isMediaBrowserhack(node: CSSNode): false | string { const name = n.name || '' if (str_equals('-moz-images-in-menus', name)) { - browserhack = '-moz-images-in-menus' + on_hack('-moz-images-in-menus') return BREAK } if (str_equals('min--moz-device-pixel-ratio', name)) { - browserhack = 'min--moz-device-pixel-ratio' + on_hack('min--moz-device-pixel-ratio') return BREAK } // Check for vendor-specific feature hacks if (str_equals('-ms-high-contrast', name)) { - browserhack = '-ms-high-contrast' + on_hack('-ms-high-contrast') return BREAK } @@ -88,7 +81,7 @@ export function isMediaBrowserhack(node: CSSNode): false | string { if (str_equals('min-resolution', name) && n.has_children) { for (const child of n) { if (child.type === DIMENSION && child.value === 0.001 && str_equals('dpcm', child.unit || '')) { - browserhack = 'min-resolution: .001dpcm' + on_hack('min-resolution: .001dpcm') return BREAK } } @@ -98,7 +91,7 @@ export function isMediaBrowserhack(node: CSSNode): false | string { if (str_equals('-webkit-min-device-pixel-ratio', name) && n.has_children) { for (const child of n) { if (child.type === NUMBER && (child.value === 0 || child.value === 10000)) { - browserhack = '-webkit-min-device-pixel-ratio' + on_hack('-webkit-min-device-pixel-ratio') return BREAK } } @@ -108,13 +101,11 @@ export function isMediaBrowserhack(node: CSSNode): false | string { if (n.has_children) { for (const child of n) { if (child.type === IDENTIFIER && child.text === '\\0') { - browserhack = '\\0' + on_hack('\\0') return BREAK } } } } }) - - return browserhack } diff --git a/src/index.ts b/src/index.ts index a65c03e1..c1ca9f4d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -241,19 +241,17 @@ function analyzeInternal(css: string, options: Options, useLo // All the AtRules in here MUST have a prelude, so we can count their names if (normalized_name === 'media') { medias.p(node.prelude.text, toLoc(node)) - let hack = isMediaBrowserhack(node.prelude) - if (hack) { + isMediaBrowserhack(node.prelude, (hack) => { mediaBrowserhacks.p(hack, toLoc(node)) complexity++ - } + }) } else if (normalized_name === 'supports') { supports.p(node.prelude.text, toLoc(node)) - let hack = isSupportsBrowserhack(node.prelude) - if (hack) { + isSupportsBrowserhack(node.prelude, (hack) => { supportsBrowserhacks.p(hack, toLoc(node)) complexity++ - } + }) } else if (normalized_name.endsWith('keyframes')) { let prelude = node.prelude.text keyframes.p(prelude, toLoc(node))