diff --git a/src/atrules/atrules.test.ts b/src/atrules/atrules.test.ts index 40fbaefc..a9713337 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', () => { @@ -222,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 { @@ -231,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', @@ -259,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")', @@ -270,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; @@ -419,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, @@ -435,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, } @@ -474,6 +488,7 @@ test('finds @imports', () => { test('finds @charsets', () => { const fixture = ` @charset "UTF-8"; + @charset "utf-8"; @charset "UTF-16"; /* No prelude */ @@ -481,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) @@ -503,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, }) }) @@ -522,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) {} @@ -538,19 +556,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, }, } @@ -570,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, @@ -585,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) }) @@ -614,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, }, } @@ -649,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, @@ -666,7 +670,7 @@ test('finds Media Features', () => { 'prefers-contrast': 1, 'min-height': 1, }, - uniquenessRatio: 8 / 8, + uniquenessRatio: 8 / 9, } expect(actual).toEqual(expected) @@ -690,6 +694,7 @@ test('analyzes @keyframes', () => { @keyframes one {} @keyframes one {} @keyframes TWO {} + @KEYFRAMES three {} /* No prelude */ @keyframes {} @@ -698,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, }, } @@ -762,6 +767,8 @@ test('analyzes container queries', () => { h2 { font-size: 1.5em; } } + @CONTAINER (width > 40em) {} + /* Example 4 */ @container (--cards) { article { @@ -797,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, diff --git a/src/atrules/atrules.ts b/src/atrules/atrules.ts index 0ba82c44..558f7923 100644 --- a/src/atrules/atrules.ts +++ b/src/atrules/atrules.ts @@ -1,13 +1,21 @@ -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, on_hack: (hack: string) => void): void { walk(node, function (n) { // Check SupportsQuery nodes for browserhack patterns if (n.type === SUPPORTS_QUERY) { @@ -15,14 +23,16 @@ 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')) { + on_hack('-webkit-appearance: none') + return BREAK + } + if (normalizedPrelude.includes('-moz-appearance:meterbar')) { + on_hack('-moz-appearance: meterbar') return BREAK } } }) - - return isBrowserhack } /** @@ -30,15 +40,19 @@ export function isSupportsBrowserhack(node: CSSNode): boolean { * @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, 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') || text.includes('\\9')) { - isBrowserhack = true + + if (text.startsWith('\\0')) { + on_hack('\\0') + return BREAK + } + + if (text.includes('\\9')) { + on_hack('\\9') return BREAK } } @@ -47,13 +61,19 @@ export function isMediaBrowserhack(node: CSSNode): boolean { if (n.type === MEDIA_FEATURE) { const name = n.name || '' + if (str_equals('-moz-images-in-menus', name)) { + on_hack('-moz-images-in-menus') + return BREAK + } + + if (str_equals('min--moz-device-pixel-ratio', name)) { + on_hack('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)) { + on_hack('-ms-high-contrast') return BREAK } @@ -61,7 +81,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 + on_hack('min-resolution: .001dpcm') return BREAK } } @@ -71,7 +91,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 + on_hack('-webkit-min-device-pixel-ratio') return BREAK } } @@ -81,13 +101,11 @@ export function isMediaBrowserhack(node: CSSNode): boolean { if (n.has_children) { for (const child of n) { if (child.type === IDENTIFIER && child.text === '\\0') { - isBrowserhack = true + on_hack('\\0') return BREAK } } } } }) - - return isBrowserhack } diff --git a/src/index.ts b/src/index.ts index 967c37f3..c1ca9f4d 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, @@ -36,26 +35,12 @@ 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 } 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 @@ -224,10 +209,11 @@ function analyzeInternal(css: string, options: Options, useLo let atruleLoc = toLoc(node) atruleNesting.push(depth) uniqueAtruleNesting.p(depth, atruleLoc) - atrules.p(node.name!, atruleLoc) + let normalized_name = basename(node.name ?? '') + 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,44 +230,44 @@ 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)) + isMediaBrowserhack(node.prelude, (hack) => { + mediaBrowserhacks.p(hack, toLoc(node)) complexity++ - } - } else if (str_equals('supports', name)) { + }) + } else if (normalized_name === 'supports') { supports.p(node.prelude.text, toLoc(node)) - if (isSupportsBrowserhack(node.prelude)) { - supportsBrowserhacks.p(node.prelude.text, toLoc(node)) + + isSupportsBrowserhack(node.prelude, (hack) => { + 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 = 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++ } // 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) { @@ -293,17 +279,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) @@ -380,21 +366,18 @@ 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 - if (isAccessibility(node)) { - a11y.p(node.text, loc) - } + isAccessibility(node, (a11y_selector) => { + a11y.p(a11y_selector, loc) + }) - let pseudos = hasPseudoClass(node) - if (pseudos !== false) { - 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 @@ -452,10 +435,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) { @@ -472,8 +452,9 @@ function analyzeInternal(css: string, options: Options, useLo let propertyLoc = toLoc(node) propertyLoc.length = property.length + let normalizedProperty = basename(property) - properties.p(property, propertyLoc) + properties.p(normalizedProperty, propertyLoc) if (is_important) { importantDeclarations++ @@ -491,7 +472,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) @@ -509,21 +490,21 @@ 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 } //#region VALUE COMPLEXITY // i.e. `background-image: -webkit-linear-gradient()` - if (isValuePrefixed(value)) { - vendorPrefixedValues.p(value.text, valueLoc) + isValuePrefixed(value, (prefixed) => { + vendorPrefixedValues.p(prefixed.toLowerCase(), valueLoc) complexity++ - } + }) // i.e. `property: value\9` if (isIe9Hack(value)) { - valueBrowserhacks.p(text, valueLoc) + valueBrowserhacks.p('\\9', valueLoc) text = text.slice(0, -2) complexity++ } @@ -534,33 +515,18 @@ 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) - ) { + if (SPACING_RESET_PROPERTIES.has(normalizedProperty)) { if (isValueReset(value)) { - resets.p(property, valueLoc) + 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') { - valueKeywords.p(item.value, valueLoc) + valueKeywords.p(item.value.toLowerCase(), valueLoc) } }) @@ -574,62 +540,77 @@ function analyzeInternal(css: string, options: Options, useLo } if (font_size) { - fontSizes.p(font_size, valueLoc) + fontSizes.p(font_size.toLowerCase(), valueLoc) } if (line_height) { - lineHeights.p(line_height, valueLoc) + lineHeights.p(line_height.toLowerCase(), valueLoc) } } // 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)) { - 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)) { + } 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)) { - lineHeights.p(text, valueLoc) - } else if (isProperty('transition', property) || isProperty('animation', property)) { - analyzeAnimation(value.children, function (item: { type: string; value: CSSNode }) { + } else if (normalizedProperty === 'line-height') { + let normalized = text.toLowerCase() + if (normalized.includes('var(')) { + lineHeights.p(text, valueLoc) + } else { + lineHeights.p(normalized, valueLoc) + } + } else if (normalizedProperty === 'transition' || normalizedProperty === 'animation') { + 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 - } 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) { - 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)) { + } 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) } @@ -639,7 +620,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 @@ -650,7 +631,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')) { @@ -676,12 +657,12 @@ 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 } if (keywords.has(identifierText)) { - valueKeywords.p(identifierText, identifierLoc) + valueKeywords.p(identifierText.toLowerCase(), identifierLoc) } // Bail out if it can't be a color name @@ -694,21 +675,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 } @@ -771,7 +753,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 } }) diff --git a/src/properties/properties.test.ts b/src/properties/properties.test.ts index 6e4bdf15..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,24 +63,35 @@ 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; + *margin: 0; + _property: 0; --custom: 1; } @@ -89,48 +103,88 @@ test('counts browser hacks', () => { } } ` - const actual = analyze(fixture).properties.browserhacks - const expected = { - '*zoom': 2, - } + const actual = analyze(fixture).properties.browserhacks + const expected = { + '*': 3, + _: 1, + } - expect(actual.total).toEqual(2) - expect(actual.totalUnique).toEqual(1) - expect(actual.unique).toEqual(expected) - expect(actual.ratio).toEqual(2 / 4) + 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', () => { 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 df24af9e..a1cfafca 100644 --- a/src/properties/property-utils.ts +++ b/src/properties/property-utils.ts @@ -1,6 +1,43 @@ -import { endsWith } from '../string-utils.js' +import { KeywordSet } from '../keyword-set.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 */ @@ -21,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() } - return property + + if (isHack(property)) { + return property.slice(1).toLowerCase() + } + + return property.toLowerCase() } diff --git a/src/selectors/a11y.test.ts b/src/selectors/a11y.test.ts new file mode 100644 index 00000000..acaf2dc9 --- /dev/null +++ b/src/selectors/a11y.test.ts @@ -0,0 +1,115 @@ +import { test, expect } from 'vitest' +import { analyze } from '../index.js' + +test('counts Accessibility selectors', () => { + const fixture = ` + .test, + [aria-hidden], + img[role="presentation"], + 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) +}) + +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: 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, + '[role="presentation"]': 1, + '[role="PRESENTATION"]': 1, // makes a difference for CSS, but not for accessibility tree + '[role="tablist"]': 1, + '[role="tabpanel"]': 1, + }, + 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/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/selectors.test.ts b/src/selectors/selectors.test.ts index 19dfc210..5fccc31f 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 = ` .💩 {} @@ -396,6 +368,7 @@ test('analyzes vendor prefixed selectors', () => { input[type=text]:-moz-placeholder { color: green; } + input:-MOZ-PLACEHOLDER {} no-prefix, fake-webkit, @@ -404,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 2e87f2e9..25c012a8 100644 --- a/src/selectors/utils.ts +++ b/src/selectors/utils.ts @@ -12,73 +12,59 @@ 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']) -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 } /** * Check if a Wallace selector is an accessibility selector (has aria-* or role attribute) */ -export function isAccessibility(selector: CSSNode): boolean { - let isA11y = false +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()) + '"' + ']' + } + 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 } -/** - * @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 } /** diff --git a/src/values/animations.test.ts b/src/values/animations.test.ts index dbd72acb..8d39142e 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,74 +81,77 @@ 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); + 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; } ` - 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: 7, + totalUnique: 5, + unique: { + '1s': 1, + '2s': 2, + '3s': 1, + '4s': 1, + '5s': 2, + }, + uniquenessRatio: 5 / 7, + } + expect(actual).toEqual(expected) }) test('finds shorthand timing functions', () => { - const fixture = ` + 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; @@ -139,22 +164,21 @@ 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: 6, + totalUnique: 2, + unique: { + linear: 3, + 'cubic-bezier(0,1,0,1)': 3, + }, + uniquenessRatio: 2 / 6, + } + 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 +188,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/browserhacks.test.ts b/src/values/browserhacks.test.ts index 68432ce0..cf413106 100644 --- a/src/values/browserhacks.test.ts +++ b/src/values/browserhacks.test.ts @@ -3,44 +3,44 @@ import { expect } from 'vitest' import { analyze } from '../index.js' test('finds hacks', () => { - const fixture = ` + const fixture = ` value-browserhacks { property: value !ie; + property: value !IE; property: value !test; property: value!nospace; 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: 5, + totalUnique: 2, + unique: { + '!ie': 4, + '\\9': 1, + }, + uniquenessRatio: 2 / 5, + } + 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) }) - 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, }, 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 = ` diff --git a/src/values/keywords.test.ts b/src/values/keywords.test.ts index ca7e148a..827b51ed 100644 --- a/src/values/keywords.test.ts +++ b/src/values/keywords.test.ts @@ -36,6 +36,28 @@ test('finds global keywords', () => { expect(actual).toEqual(expected) }) +test('normalizes keyword casing', () => { + const fixture = ` + test { + /* Global values */ + line-height: inherit; + line-height: INHERIT; + font: 10px/12px INHERIT; + } + ` + const actual = analyze(fixture).values.keywords + const expected = { + total: 3, + totalUnique: 1, + unique: { + inherit: 3, + }, + uniquenessRatio: 1 / 3, + } + + expect(actual).toEqual(expected) +}) + test('finds global keywords in shorthands', () => { let fixture = ` a { 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 = ` diff --git a/src/values/resets.test.ts b/src/values/resets.test.ts index d5c7e140..0f4da9e5 100644 --- a/src/values/resets.test.ts +++ b/src/values/resets.test.ts @@ -45,17 +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({ '-webkit-margin': 1 }) + }`).values.resets + expect(actual.unique).toEqual({ margin: 1 }) +}) + +test('accepts browserhacks', () => { + let actual = analyze(`t { + *margin: 0; + }`).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 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 { 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..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): boolean { - let isPrefixed = false - +export function isValuePrefixed(node: CSSNode, on_value: (value: string) => void): void { walk(node, function (child) { if (child.is_vendor_prefixed) { - isPrefixed = true - return BREAK + // .name in case of Identifier or Function, .text as fallback + on_value(child.name || child.text) } }) - - return isPrefixed }