Skip to content

feat: add analysis for spacing resets #467

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 51 additions & 15 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { isSupportsBrowserhack, isMediaBrowserhack } from './atrules/atrules.js'
import { getCombinators, getComplexity, isAccessibility, isPrefixed, hasPseudoClass } from './selectors/utils.js'
import { colorFunctions, colorKeywords, namedColors, systemColors } from './values/colors.js'
import { destructure, isSystemFont } from './values/destructure-font-shorthand.js'
import { isValueKeyword, keywords } from './values/values.js'
import { isValueKeyword, keywords, isValueReset } from './values/values.js'
import { analyzeAnimation } from './values/animations.js'
import { isValuePrefixed } from './values/vendor-prefix.js'
import { ContextCollection } from './context-collection.js'
Expand Down Expand Up @@ -193,6 +193,7 @@ export function analyze(css, options = {}) {
let gradients = new Collection(useLocations)
let valueKeywords = new Collection(useLocations)
let borderRadiuses = new ContextCollection(useLocations)
let resets = new Collection(useLocations)

walk(ast, function (node) {
switch (node.type) {
Expand Down Expand Up @@ -452,6 +453,7 @@ export function analyze(css, options = {}) {
break
}

/** @type {import('css-tree').Declaration} */
let declaration = this.declaration
let { property, important } = declaration
let complexity = 1
Expand Down Expand Up @@ -482,10 +484,31 @@ export function analyze(css, options = {}) {

// Process properties first that don't have colors,
// so we can avoid further walking them;
if (isProperty('z-index', property)) {
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 (isValueReset(node)) {
resets.p(property, declaration.loc)
}
}
else if (isProperty('z-index', property)) {
zindex.p(stringifyNode(node), loc)
return this.skip
} else if (isProperty('font', property)) {
}
else if (isProperty('font', property)) {
if (isSystemFont(node)) return

let { font_size, line_height, font_family } = destructure(node, stringifyNode, function (item) {
Expand All @@ -507,48 +530,58 @@ export function analyze(css, options = {}) {
}

break
} else if (isProperty('font-size', property)) {
}
else if (isProperty('font-size', property)) {
if (!isSystemFont(node)) {
fontSizes.p(stringifyNode(node), loc)
}
break
} else if (isProperty('font-family', property)) {
}
else if (isProperty('font-family', property)) {
if (!isSystemFont(node)) {
fontFamilies.p(stringifyNode(node), loc)
}
break
} else if (isProperty('line-height', property)) {
}
else if (isProperty('line-height', property)) {
lineHeights.p(stringifyNode(node), loc)
} else if (isProperty('transition', property) || isProperty('animation', property)) {
}
else if (isProperty('transition', property) || isProperty('animation', property)) {
analyzeAnimation(children, function (item) {
if (item.type === 'fn') {
timingFunctions.p(stringifyNode(item.value), loc)
} else if (item.type === 'duration') {
}
else if (item.type === 'duration') {
durations.p(stringifyNode(item.value), loc)
} else if (item.type === 'keyword') {
}
else if (item.type === 'keyword') {
valueKeywords.p(stringifyNode(item.value), loc)
}
})
break
} else if (isProperty('animation-duration', property) || isProperty('transition-duration', property)) {
}
else if (isProperty('animation-duration', property) || isProperty('transition-duration', property)) {
if (children && children.size > 1) {
children.forEach(child => {
if (child.type !== Operator) {
durations.p(stringifyNode(child), loc)
}
})
} else {
}
else {
durations.p(stringifyNode(node), loc)
}
break
} else if (isProperty('transition-timing-function', property) || isProperty('animation-timing-function', property)) {
}
else if (isProperty('transition-timing-function', property) || isProperty('animation-timing-function', property)) {
if (children && children.size > 1) {
children.forEach(child => {
if (child.type !== Operator) {
timingFunctions.p(stringifyNode(child), loc)
}
})
} else {
}
else {
timingFunctions.p(stringifyNode(node), loc)
}
break
Expand All @@ -568,12 +601,14 @@ export function analyze(css, options = {}) {
borderRadiuses.push(stringifyNode(node), property, loc)
}
break
} else if (isProperty('text-shadow', property)) {
}
else if (isProperty('text-shadow', property)) {
if (!isValueKeyword(node)) {
textShadows.p(stringifyNode(node), loc)
}
// no break here: potentially contains colors
} else if (isProperty('box-shadow', property)) {
}
else if (isProperty('box-shadow', property)) {
if (!isValueKeyword(node)) {
boxShadows.p(stringifyNode(node), loc)
}
Expand Down Expand Up @@ -936,6 +971,7 @@ export function analyze(css, options = {}) {
units: units.count(),
complexity: valueComplexity,
keywords: valueKeywords.c(),
resets: resets.c(),
},
__meta__: {
parseTime: startAnalysis - startParse,
Expand Down
6 changes: 6 additions & 0 deletions src/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,12 @@ Api("handles empty input gracefully", () => {
unique: {},
uniquenessRatio: 0,
},
resets: {
total: 0,
totalUnique: 0,
unique: {},
uniquenessRatio: 0,
},
},
}

Expand Down
175 changes: 175 additions & 0 deletions src/values/resets.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { analyze } from "../index.js"
import { suite } from 'uvu'
import * as assert from 'uvu/assert'

const test = suite("resets")

test('does not report false positives', () => {
let actual = analyze(`t {
margin: 0px 10px;
margin: 10px 10px;
margin: auto 10px;
margin: 10px auto;
}`)
let resets = actual.values.resets
assert.is(resets.total, 0)
assert.equal(resets.unique, {})
})

test('accepts zeroes with units', () => {
let actual = analyze(`t {
margin: 0px 0em 0pc 0vw;
padding: 0px 0dvh 0rem 0in;
}`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'margin': 1, 'padding': 1 })
})

test('crazy notations', () => {
let actual = analyze(`t {
margin: 0;
margin: -0;
margin: +0;
margin: 0px;
margin: -0px;
margin: +0px;
margin: 0.0;
margin: -0.0;
margin: +0.0;
margin: 0.0e0;
margin: -0.0e0;
margin: +0.0e0;
}`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'margin': 12 })
})

test('accepts weird casing', () => {
let actual = analyze(`t {
MARGIN: 0;
}`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'MARGIN': 1 })
})

test('accepts vendor prefixes', () => {
let actual = analyze(`t {
-webkit-margin: 0;
}`)
let resets = actual.values.resets
assert.equal(resets.unique, { '-webkit-margin': 1 })
})

// Test all properties

test('margin: 0', () => {
let actual = analyze(`t { margin: 0; }`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'margin': 1 })
})

test('margin-top: 0', () => {
let actual = analyze(`t { margin-top: 0; }`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'margin-top': 1 })
})

test('margin-right: 0', () => {
let actual = analyze(`t { margin-right: 0; }`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'margin-right': 1 })
})

test('margin-bottom: 0', () => {
let actual = analyze(`t { margin-bottom: 0; }`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'margin-bottom': 1 })
})

test('margin-left: 0', () => {
let actual = analyze(`t { margin-left: 0; }`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'margin-left': 1 })
})

test('margin-inline: 0', () => {
let actual = analyze(`t { margin-inline: 0; }`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'margin-inline': 1 })
})

test('margin-block: 0', () => {
let actual = analyze(`t { margin-block: 0; }`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'margin-block': 1 })
})

test('padding: 0', () => {
let actual = analyze(`t { padding: 0; }`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'padding': 1 })
})

test('padding-top: 0', () => {
let actual = analyze(`t { padding-top: 0; }`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'padding-top': 1 })
})

test('padding-right: 0', () => {
let actual = analyze(`t { padding-right: 0; }`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'padding-right': 1 })
})

test('padding-bottom: 0', () => {
let actual = analyze(`t { padding-bottom: 0; }`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'padding-bottom': 1 })
})

test('padding-left: 0', () => {
let actual = analyze(`t { padding-left: 0; }`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'padding-left': 1 })
})

test('padding-inline: 0', () => {
let actual = analyze(`t { padding-inline: 0; }`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'padding-inline': 1 })
})

test('padding-block: 0', () => {
let actual = analyze(`t { padding-block: 0; }`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'padding-block': 1 })
})

// Shorthands

test('margin-inline: 0 0', () => {
let actual = analyze(`t { margin-inline: 0 0; }`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'margin-inline': 1 })
})

test('padding-inline: 0 0', () => {
let actual = analyze(`t { padding-inline: 0 0; }`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'padding-inline': 1 })
})

test('margin-block: 0 0', () => {
let actual = analyze(`t { margin-block: 0 0; }`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'margin-block': 1 })
})

test('padding-block: 0 0', () => {
let actual = analyze(`t { padding-block: 0 0; }`)
let resets = actual.values.resets
assert.equal(resets.unique, { 'padding-block': 1 })
})

test.run()
24 changes: 23 additions & 1 deletion src/values/values.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { KeywordSet } from "../keyword-set.js"
import { Identifier } from "../css-tree-node-types.js"
import { Identifier, Value, Nr, Dimension } from "../css-tree-node-types.js"

export const keywords = new KeywordSet([
'auto',
Expand All @@ -24,3 +24,25 @@ export function isValueKeyword(node) {
let firstChild = children.first
return firstChild.type === Identifier && keywords.has(firstChild.name)
}

/**
* @param {string} string
* @returns {boolean}
*/
function isZero(string) {
return parseFloat(string) === 0
}

/**
* Test whether a value is a reset (0, 0px, -0.0e0 etc.)
* @param {import('css-tree').Value} node
*/
export function isValueReset(node) {
for (let child of node.children.toArray()) {
if (child.type === Nr && isZero(child.value)) continue
if (child.type === Dimension && isZero(child.value)) continue
return false
}

return true
}