diff --git a/src/aggregate-collection.test.ts b/src/aggregate-collection.test.ts index cedc8da5..c05a752e 100644 --- a/src/aggregate-collection.test.ts +++ b/src/aggregate-collection.test.ts @@ -2,7 +2,7 @@ import { test, expect } from 'vitest' import { AggregateCollection } from './aggregate-collection.js' test('aggregates correctly', () => { - const fixture = new AggregateCollection() + const fixture = new AggregateCollection(true) fixture.push(1) fixture.push(2) fixture.push(25) diff --git a/src/aggregate-collection.ts b/src/aggregate-collection.ts index be303a03..89b639cd 100644 --- a/src/aggregate-collection.ts +++ b/src/aggregate-collection.ts @@ -1,46 +1,18 @@ -/** - * Find the mode (most occurring value) in an array of Numbers - * Takes the mean/average of multiple values if multiple values occur the same amount of times. - * - * @see https://github.com/angus-c/just/blob/684af9ca0c7808bc78543ec89379b1fdfce502b1/packages/array-mode/index.js - * @param arr - Array to find the mode value for - * @returns mode - The `mode` value of `arr` - */ -function Mode(arr: unknown[]): number { - let frequencies = new Map() - let maxOccurrences = -1 - let maxOccurenceCount = 0 - let sum = 0 - let len = arr.length - - for (let i = 0; i < len; i++) { - let element = arr[i] - let updatedCount = (frequencies.get(element) || 0) + 1 - frequencies.set(element, updatedCount) - - if (updatedCount > maxOccurrences) { - maxOccurrences = updatedCount - maxOccurenceCount = 0 - sum = 0 - } - - if (updatedCount >= maxOccurrences) { - maxOccurenceCount++ - // @ts-expect-error TODO: fix this - sum += element - } - } - - return sum / maxOccurenceCount -} - export class AggregateCollection { - #items: number[] + #min: number + #max: number #sum: number + #count: number + #frequencies: Map + #items: number[] | null - constructor() { - this.#items = [] + constructor(samples = false) { + this.#min = Infinity + this.#max = -Infinity this.#sum = 0 + this.#count = 0 + this.#frequencies = new Map() + this.#items = samples ? [] : null } /** @@ -48,16 +20,25 @@ export class AggregateCollection { * @param item - The item to add */ push(item: number) { - this.#items.push(item) this.#sum += item + this.#count++ + if (item < this.#min) this.#min = item + if (item > this.#max) this.#max = item + + let freq = (this.#frequencies.get(item) || 0) + 1 + this.#frequencies.set(item, freq) + + if (this.#items !== null) { + this.#items.push(item) + } } size() { - return this.#items.length + return this.#count } aggregate() { - let len = this.#items.length + let len = this.#count if (len === 0) { return { @@ -70,25 +51,36 @@ export class AggregateCollection { } } - // TODO: can we avoid this sort()? It's slow - let sorted = this.#items.slice().sort((a, b) => a - b) - let min = sorted[0]! - let max = sorted[len - 1]! + // Find max frequency for mode calculation — O(k) where k = unique values + let maxFreq = 0 + for (let freq of this.#frequencies.values()) { + if (freq > maxFreq) maxFreq = freq + } + + // Average of all values with max frequency (matches prior behavior) + let modeSum = 0 + let modeCount = 0 + for (let [val, freq] of this.#frequencies) { + if (freq === maxFreq) { + modeSum += val + modeCount++ + } + } - let mode = Mode(sorted) - let sum = this.#sum + let min = this.#min + let max = this.#max return { min, max, - mean: sum / len, - mode, + mean: this.#sum / len, + mode: modeSum / modeCount, range: max - min, - sum: sum, + sum: this.#sum, } } - toArray() { - return this.#items + toArray(): number[] { + return this.#items ?? [] } } diff --git a/src/atrules/atrules.test.ts b/src/atrules/atrules.test.ts index 689e32a2..85afe7d3 100644 --- a/src/atrules/atrules.test.ts +++ b/src/atrules/atrules.test.ts @@ -319,9 +319,10 @@ test('finds @font-face (with locations)', () => { src: local('Input Mono') url("https://url-to-input-mono.woff"); } }` - const actual = analyze(fixture, { + const result = analyze(fixture, { useLocations: true, - }).atrules.fontface.uniqueWithLocations + }) + const actual = result.locations['atrules.fontface'] const expected = { 5: [ { @@ -1047,7 +1048,7 @@ test('tracks nesting depth', () => { } } ` - const actual = analyze(fixture).atrules.nesting + const actual = analyze(fixture, { samples: true }).atrules.nesting const expected = { min: 0, max: 1, diff --git a/src/collection.test.ts b/src/collection.test.ts index fd6d7292..3cf87a31 100644 --- a/src/collection.test.ts +++ b/src/collection.test.ts @@ -49,14 +49,16 @@ test('count with useLocations=true', () => { collection.p('a', loc) collection.p('a', loc) - let pos = { offset: 1, length: 1, line: 1, column: 1 } let count = collection.c() expect(count).toEqual({ total: 2, totalUnique: 1, - unique: {}, + unique: { a: 2 }, uniquenessRatio: 0.5, - uniqueWithLocations: { a: [pos, pos] }, }) - expectTypeOf(count['uniqueWithLocations']).toMatchObjectType() + expectTypeOf(count['uniqueWithLocations']).toBeUndefined() + + let locs = collection.locs() + let pos = { offset: 1, length: 1, line: 1, column: 1 } + expect(locs).toEqual({ a: [pos, pos] }) }) diff --git a/src/collection.ts b/src/collection.ts index e008c7cc..6342f857 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -7,36 +7,31 @@ export type Location = { export type UniqueWithLocations = Record -export type CollectionCount = { +export type CollectionCount = { total: number totalUnique: number unique: Record uniquenessRatio: number -} & (WithLocations extends true ? { uniqueWithLocations: UniqueWithLocations } : { uniqueWithLocations?: undefined }) +} -export class Collection { +export class Collection { #items: Map #total: number - #nodes: number[] = [] - #useLocations: UseLocations + #nodes: number[] | null + #useLocations: boolean - constructor(useLocations: UseLocations = false as UseLocations) { + constructor(useLocations = false) { this.#items = new Map() this.#total = 0 - - if (useLocations) { - this.#nodes = [] - } - + this.#nodes = useLocations ? [] : null this.#useLocations = useLocations } p(item: string | number, node_location: Location) { let index = this.#total - if (this.#useLocations) { + if (this.#nodes !== null) { let position = index * 4 - this.#nodes[position] = node_location.line this.#nodes[position + 1] = node_location.column this.#nodes[position + 2] = node_location.offset @@ -44,8 +39,7 @@ export class Collection { } if (this.#items.has(item)) { - let list = this.#items.get(item)! - list.push(index) + this.#items.get(item)!.push(index) this.#total++ return } @@ -58,49 +52,45 @@ export class Collection { return this.#total } - c(): CollectionCount { - let uniqueWithLocations: Map = new Map() + /** Returns counts only — never location data */ + c(): CollectionCount { let unique: Record = {} - let useLocations = this.#useLocations let items = this.#items - let _nodes = this.#nodes - let size = items.size items.forEach((list, key) => { - if (useLocations) { - let nodes = list.map(function (index) { - let position = index * 4 - return { - line: _nodes[position]!, - column: _nodes[position + 1]!, - offset: _nodes[position + 2]!, - length: _nodes[position + 3]!, - } - }) - uniqueWithLocations.set(key, nodes) - } else { - unique[key] = list.length - } + unique[key] = list.length }) let total = this.#total - - if (useLocations) { - return { - total, - totalUnique: size, - unique, - uniquenessRatio: total === 0 ? 0 : size / total, - uniqueWithLocations: Object.fromEntries(uniqueWithLocations), - } as unknown as CollectionCount - } + let size = items.size return { total, totalUnique: size, unique, uniquenessRatio: total === 0 ? 0 : size / total, - uniqueWithLocations: undefined, - } as unknown as CollectionCount + } + } + + /** Returns location data per unique value, or undefined when not tracking locations */ + locs(): UniqueWithLocations | undefined { + if (!this.#useLocations || this.#nodes === null) return undefined + + let result: UniqueWithLocations = {} + let _nodes = this.#nodes + + this.#items.forEach((list, key) => { + result[String(key)] = list.map(function (index) { + let position = index * 4 + return { + line: _nodes[position]!, + column: _nodes[position + 1]!, + offset: _nodes[position + 2]!, + length: _nodes[position + 3]!, + } + }) + }) + + return result } } diff --git a/src/context-collection.ts b/src/context-collection.ts index 0292a444..1d249b15 100644 --- a/src/context-collection.ts +++ b/src/context-collection.ts @@ -1,11 +1,11 @@ -import { Collection, type CollectionCount, type Location } from './collection.js' +import { Collection, type CollectionCount, type Location, type UniqueWithLocations } from './collection.js' -export class ContextCollection { - #list: Collection - #contexts: Map> - #useLocations: UseLocations +export class ContextCollection { + #list: Collection + #contexts: Map + #useLocations: boolean - constructor(useLocations: UseLocations) { + constructor(useLocations = false) { this.#list = new Collection(useLocations) this.#contexts = new Map() this.#useLocations = useLocations @@ -28,7 +28,7 @@ export class ContextCollection { } count() { - let itemsPerContext: Map> = new Map() + let itemsPerContext: Map = new Map() for (let [context, value] of this.#contexts.entries()) { itemsPerContext.set(context, value.c()) @@ -38,4 +38,20 @@ export class ContextCollection { itemsPerContext: Object.fromEntries(itemsPerContext), }) } + + /** Returns location data for the top-level list, or undefined when not tracking locations */ + locs(): UniqueWithLocations | undefined { + return this.#list.locs() + } + + /** Returns location data per context, or undefined when not tracking locations */ + locsPerContext(): Record | undefined { + if (!this.#useLocations) return undefined + + let result: Record = {} + for (let [context, collection] of this.#contexts.entries()) { + result[context] = collection.locs() ?? {} + } + return result + } } diff --git a/src/declarations/declarations.test.ts b/src/declarations/declarations.test.ts index 6d217469..de1d32ba 100644 --- a/src/declarations/declarations.test.ts +++ b/src/declarations/declarations.test.ts @@ -171,7 +171,7 @@ test('tracks nesting depth', () => { } } ` - const actual = analyze(fixture).declarations.nesting + const actual = analyze(fixture, { samples: true }).declarations.nesting const expected = { min: 0, max: 2, diff --git a/src/index.test.ts b/src/index.test.ts index bf932eec..246aa22a 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -265,7 +265,6 @@ test('handles empty input gracefully', () => { mode: 0, range: 0, sum: 0, - items: [], total: 0, totalUnique: 0, unique: {}, @@ -285,7 +284,6 @@ test('handles empty input gracefully', () => { mode: 0, range: 0, sum: 0, - items: [], unique: {}, total: 0, totalUnique: 0, @@ -298,7 +296,6 @@ test('handles empty input gracefully', () => { mode: 0, range: 0, sum: 0, - items: [], total: 0, totalUnique: 0, unique: {}, @@ -311,7 +308,6 @@ test('handles empty input gracefully', () => { mode: 0, range: 0, sum: 0, - items: [], unique: {}, total: 0, totalUnique: 0, @@ -324,7 +320,6 @@ test('handles empty input gracefully', () => { mode: 0, range: 0, sum: 0, - items: [], unique: {}, total: 0, totalUnique: 0, @@ -341,7 +336,6 @@ test('handles empty input gracefully', () => { sum: [0, 0, 0], mean: [0, 0, 0], mode: [0, 0, 0], - items: [], unique: {}, total: 0, totalUnique: 0, @@ -358,7 +352,6 @@ test('handles empty input gracefully', () => { totalUnique: 0, unique: {}, uniquenessRatio: 0, - items: [], }, nesting: { min: 0, @@ -367,7 +360,6 @@ test('handles empty input gracefully', () => { mode: 0, range: 0, sum: 0, - items: [], total: 0, totalUnique: 0, unique: {}, @@ -452,7 +444,6 @@ test('handles empty input gracefully', () => { mode: 0, range: 0, sum: 0, - items: [], total: 0, totalUnique: 0, unique: {}, diff --git a/src/index.test.ts.bak b/src/index.test.ts.bak new file mode 100644 index 00000000..bf932eec --- /dev/null +++ b/src/index.test.ts.bak @@ -0,0 +1,664 @@ +import { test, expect, describe } from 'vitest' +import { + analyze, + compareSpecificity, + selectorComplexity, + isAccessibilitySelector, + isSelectorPrefixed, + isMediaBrowserhack, + isSupportsBrowserhack, + isPropertyHack, + isValuePrefixed, + hasVendorPrefix, + cssKeywords, + KeywordSet, + // Color exports + namedColors, + systemColors, + colorFunctions, + colorKeywords, + type UniqueWithLocations, + type Location, + type Specificity, +} from './index.js' + +describe('Public API', () => { + test("exposes the 'analyze' method", () => { + expect(typeof analyze).toBe('function') + }) + + test('exposes the "compareSpecificity" method', () => { + expect(typeof compareSpecificity).toBe('function') + }) + + test('exposes the "selectorComplexity" method', () => { + expect(typeof selectorComplexity).toBe('function') + }) + + test('exposes the "isSelectorPrefixed" method', () => { + expect(typeof isSelectorPrefixed).toBe('function') + }) + + test('exposes the "isAccessibilitySelector" method', () => { + expect(typeof isAccessibilitySelector).toBe('function') + }) + + test('exposes the "isMediaBrowserhack" method', () => { + expect(typeof isMediaBrowserhack).toBe('function') + }) + + test('exposes the "isSupportsBrowserhack" method', () => { + expect(typeof isSupportsBrowserhack).toBe('function') + }) + + test('exposes the "isPropertyHack" method', () => { + expect(typeof isPropertyHack).toBe('function') + }) + + test('exposes the "isValuePrefixed" method', () => { + expect(typeof isValuePrefixed).toBe('function') + }) + + test('exposes the "hasVendorPrefix" method', () => { + expect(typeof hasVendorPrefix).toBe('function') + }) + + test('exposes the "compareSpecificity" method', () => { + expect(typeof compareSpecificity).toBe('function') + }) + + test('exposes the namedColors KeywordSet', () => { + expect(namedColors.has('Red')).toBeTruthy() + }) + + test('exposes the systemColors KeywordSet', () => { + expect(systemColors.has('LinkText')).toBeTruthy() + }) + + test('exposes the colorFunctions KeywordSet', () => { + expect(colorFunctions.has('okLAB')).toBeTruthy() + }) + + test('exposes the colorKeywords KeywordSet', () => { + expect(colorKeywords.has('TRANSPARENT')).toBeTruthy() + }) + + test('exposes CSS keywords KeywordSet', () => { + expect(cssKeywords.has('Auto')).toBeTruthy() + expect(cssKeywords.has('inherit')).toBeTruthy() + }) + + test('exposes the KeywordSet class', () => { + expect(typeof KeywordSet).toBe('function') + expect(new KeywordSet([]).constructor.name).toBe('KeywordSet') + }) + + test('exposes Location type', () => { + let location: Location = { + offset: 0, + line: 0, + length: 0, + column: 0, + } + expect(location).toHaveProperty('line') + }) + + test('exposes UniqueWithLocations type', () => { + let location: Location = { + offset: 0, + line: 0, + length: 0, + column: 0, + } + let uniqueWithLocations: UniqueWithLocations = { + 'my-item': [location], + } + expect(uniqueWithLocations).toHaveProperty('my-item') + }) + + test('exposes Specificity type', () => { + let specificity: Specificity = [1, 1, 1] + expect(specificity).toHaveLength(3) + }) +}) + +test('does not break on CSS Syntax Errors', () => { + expect(() => analyze('test, {}')).not.toThrow() + expect(() => analyze('test { color red }')).not.toThrow() +}) + +test('handles empty input gracefully', () => { + const actual = analyze('') + // @ts-expect-error Just for testing purposes + delete actual.__meta__ + const expected = { + stylesheet: { + sourceLinesOfCode: 0, + linesOfCode: 1, + size: 0, + comments: { + total: 0, + size: 0, + }, + embeddedContent: { + size: { + total: 0, + ratio: 0, + }, + types: { + total: 0, + totalUnique: 0, + uniquenessRatio: 0, + unique: {}, + }, + }, + complexity: 0, + }, + atrules: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + fontface: { + total: 0, + totalUnique: 0, + unique: [], + uniquenessRatio: 0, + }, + import: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + media: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + browserhacks: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + features: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + }, + charset: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + supports: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + browserhacks: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + }, + keyframes: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + prefixed: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + ratio: 0, + }, + }, + container: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + names: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + }, + layer: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + property: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + scope: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + complexity: { + min: 0, + max: 0, + mean: 0, + mode: 0, + range: 0, + sum: 0, + }, + nesting: { + min: 0, + max: 0, + mean: 0, + mode: 0, + range: 0, + sum: 0, + items: [], + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + }, + rules: { + total: 0, + empty: { + total: 0, + ratio: 0, + }, + sizes: { + min: 0, + max: 0, + mean: 0, + mode: 0, + range: 0, + sum: 0, + items: [], + unique: {}, + total: 0, + totalUnique: 0, + uniquenessRatio: 0, + }, + nesting: { + min: 0, + max: 0, + mean: 0, + mode: 0, + range: 0, + sum: 0, + items: [], + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + selectors: { + min: 0, + max: 0, + mean: 0, + mode: 0, + range: 0, + sum: 0, + items: [], + unique: {}, + total: 0, + totalUnique: 0, + uniquenessRatio: 0, + }, + declarations: { + min: 0, + max: 0, + mean: 0, + mode: 0, + range: 0, + sum: 0, + items: [], + unique: {}, + total: 0, + totalUnique: 0, + uniquenessRatio: 0, + }, + }, + selectors: { + total: 0, + totalUnique: 0, + uniquenessRatio: 0, + specificity: { + min: [0, 0, 0], + max: [0, 0, 0], + sum: [0, 0, 0], + mean: [0, 0, 0], + mode: [0, 0, 0], + items: [], + unique: {}, + total: 0, + totalUnique: 0, + uniquenessRatio: 0, + }, + complexity: { + min: 0, + max: 0, + mean: 0, + mode: 0, + range: 0, + sum: 0, + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + items: [], + }, + nesting: { + min: 0, + max: 0, + mean: 0, + mode: 0, + range: 0, + sum: 0, + items: [], + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + id: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + ratio: 0, + }, + pseudoClasses: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + pseudoElements: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + accessibility: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + ratio: 0, + }, + attributes: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + keyframes: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + prefixed: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + ratio: 0, + }, + combinators: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + }, + declarations: { + total: 0, + totalUnique: 0, + uniquenessRatio: 0, + importants: { + total: 0, + ratio: 0, + inKeyframes: { + total: 0, + ratio: 0, + }, + }, + complexity: { + min: 0, + max: 0, + mean: 0, + mode: 0, + range: 0, + sum: 0, + }, + nesting: { + min: 0, + max: 0, + mean: 0, + mode: 0, + range: 0, + sum: 0, + items: [], + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + }, + properties: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + prefixed: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + ratio: 0, + }, + custom: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + ratio: 0, + importants: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + ratio: 0, + }, + }, + browserhacks: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + ratio: 0, + }, + complexity: { + min: 0, + max: 0, + mean: 0, + mode: 0, + range: 0, + sum: 0, + }, + }, + values: { + colors: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + itemsPerContext: {}, + formats: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + }, + gradients: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + fontFamilies: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + fontSizes: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + lineHeights: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + zindexes: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + textShadows: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + boxShadows: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + borderRadiuses: { + total: 0, + totalUnique: 0, + unique: {}, + itemsPerContext: {}, + uniquenessRatio: 0, + }, + animations: { + durations: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + timingFunctions: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + }, + prefixes: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + browserhacks: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + units: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + itemsPerContext: {}, + }, + complexity: { + min: 0, + max: 0, + mean: 0, + mode: 0, + range: 0, + sum: 0, + }, + keywords: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + resets: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + displays: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, + }, + } + + expect(actual).toEqual(expected) +}) + +test('has metadata', () => { + const fixture = Array.from({ length: 100 }) + .map( + (_) => ` + html { + font: 1em/1 sans-serif; + color: rgb(0 0 0 / 0.5); + } + + @media screen { + @supports (display: grid) { + test::after :where(test) :is(done) { + display: grid; + color: #f00; + } + } + } + `, + ) + .join('') + + const result = analyze(fixture) + const actual = result.__meta__ + + expect(typeof actual.parseTime).toBe('number') + expect(actual.parseTime).toBeGreaterThan(0) + + expect(typeof actual.analyzeTime).toBe('number') + expect(actual.analyzeTime).toBeGreaterThan(0) + + expect(typeof actual.total).toBe('number') + expect(actual.total).toBeGreaterThan(0) +}) diff --git a/src/index.ts b/src/index.ts index 2327c8bb..40e8c06b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,12 +33,13 @@ import { 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' -import { Collection, type Location } from './collection.js' +import { Collection, type Location, type UniqueWithLocations } from './collection.js' import { AggregateCollection } from './aggregate-collection.js' import { endsWith, unquote } from './string-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' +import { type Options, shouldRun } from './options.js' export type Specificity = [number, number, number] @@ -47,24 +48,60 @@ function ratio(part: number, total: number): number { return part / total } -export type Options = { - /** @description Use Locations (`{ 'item': [{ line, column, offset, length }] }`) instead of a regular count per occurrence (`{ 'item': 3 }`) */ - useLocations?: boolean -} +// Re-export Options for external consumers +export type { Options } -export function analyze(css: string, options?: Options & { useLocations?: false | undefined }): ReturnType> -export function analyze(css: string, options: Options & { useLocations: true }): ReturnType> +export function analyze(css: string, options?: Options): ReturnType export function analyze(css: string, options: Options = {}): any { - const useLocations = options.useLocations === true - if (useLocations) { - return analyzeInternal(css, options, true) - } - return analyzeInternal(css, options, false) + return analyzeInternal(css, options) } -function analyzeInternal(css: string, options: Options, useLocations: T) { +function analyzeInternal(css: string, options: Options) { let start = Date.now() + const useLocations = options.useLocations === true + const samples = options.samples === true + const only = options.only + + // Precompute which metric groups to run + const runStylesheet = shouldRun(only, 'stylesheet') + const runAtrules = shouldRun(only, 'atrules') + const runRules = shouldRun(only, 'rules') + const runSelectors = shouldRun(only, 'selectors') + const runDeclarations = shouldRun(only, 'declarations') + const runProperties = shouldRun(only, 'properties') + const runValues = shouldRun(only, 'values') + + // Sub-metric flags + const runSelectors_specificity = shouldRun(only, 'selectors.specificity') + const runSelectors_complexity = shouldRun(only, 'selectors.complexity') + const runSelectors_id = shouldRun(only, 'selectors.id') + const runSelectors_pseudoClasses = shouldRun(only, 'selectors.pseudoClasses') + const runSelectors_pseudoElements = shouldRun(only, 'selectors.pseudoElements') + const runSelectors_a11y = shouldRun(only, 'selectors.accessibility') + const runSelectors_attributes = shouldRun(only, 'selectors.attributes') + const runSelectors_combinators = shouldRun(only, 'selectors.combinators') + const runSelectors_nesting = shouldRun(only, 'selectors.nesting') + const runSelectors_keyframes = shouldRun(only, 'selectors.keyframes') + const runSelectors_prefixed = shouldRun(only, 'selectors.prefixed') + + const runValues_colors = shouldRun(only, 'values.colors') + const runValues_gradients = shouldRun(only, 'values.gradients') + const runValues_fontFamilies = shouldRun(only, 'values.fontFamilies') + const runValues_fontSizes = shouldRun(only, 'values.fontSizes') + const runValues_lineHeights = shouldRun(only, 'values.lineHeights') + const runValues_zindexes = shouldRun(only, 'values.zindexes') + const runValues_textShadows = shouldRun(only, 'values.textShadows') + const runValues_boxShadows = shouldRun(only, 'values.boxShadows') + const runValues_borderRadiuses = shouldRun(only, 'values.borderRadiuses') + const runValues_animations = shouldRun(only, 'values.animations') + const runValues_prefixes = shouldRun(only, 'values.prefixes') + const runValues_browserhacks = shouldRun(only, 'values.browserhacks') + const runValues_units = shouldRun(only, 'values.units') + const runValues_keywords = shouldRun(only, 'values.keywords') + const runValues_resets = shouldRun(only, 'values.resets') + const runValues_displays = shouldRun(only, 'values.displays') + // Stylesheet let linesOfCode = (css.match(/\n/g) || []).length + 1 let totalComments = 0 @@ -77,7 +114,7 @@ function analyzeInternal(css: string, options: Options, useLo { size: number count: number - uniqueWithLocations?: Location[] + locations?: Location[] } >, } @@ -95,7 +132,7 @@ function analyzeInternal(css: string, options: Options, useLo // Atrules let atrules = new Collection(useLocations) - let atRuleComplexities = new AggregateCollection() + let atRuleComplexities = new AggregateCollection(samples) /** @type {Record[]} */ let fontfaces: Record[] = [] let fontfaces_with_loc = new Collection(useLocations) @@ -113,19 +150,19 @@ function analyzeInternal(css: string, options: Options, useLo let containerNames = new Collection(useLocations) let registeredProperties = new Collection(useLocations) let scopes = new Collection(useLocations) - let atruleNesting = new AggregateCollection() + let atruleNesting = new AggregateCollection(samples) let uniqueAtruleNesting = new Collection(useLocations) // Rules let totalRules = 0 let emptyRules = 0 - let ruleSizes = new AggregateCollection() - let selectorsPerRule = new AggregateCollection() - let declarationsPerRule = new AggregateCollection() + let ruleSizes = new AggregateCollection(samples) + let selectorsPerRule = new AggregateCollection(samples) + let declarationsPerRule = new AggregateCollection(samples) let uniqueRuleSize = new Collection(useLocations) let uniqueSelectorsPerRule = new Collection(useLocations) let uniqueDeclarationsPerRule = new Collection(useLocations) - let ruleNesting = new AggregateCollection() + let ruleNesting = new AggregateCollection(samples) let uniqueRuleNesting = new Collection(useLocations) // Selectors @@ -134,11 +171,11 @@ function analyzeInternal(css: string, options: Options, useLo let prefixedSelectors = new Collection(useLocations) let maxSpecificity: Specificity | undefined let minSpecificity: Specificity | undefined - let specificityA = new AggregateCollection() - let specificityB = new AggregateCollection() - let specificityC = new AggregateCollection() + let specificityA = new AggregateCollection(samples) + let specificityB = new AggregateCollection(samples) + let specificityC = new AggregateCollection(samples) let uniqueSpecificities = new Collection(useLocations) - let selectorComplexities = new AggregateCollection() + let selectorComplexities = new AggregateCollection(samples) let uniqueSelectorComplexities = new Collection(useLocations) let specificities: Specificity[] = [] let ids = new Collection(useLocations) @@ -147,17 +184,17 @@ function analyzeInternal(css: string, options: Options, useLo let pseudoElements = new Collection(useLocations) let attributeSelectors = new Collection(useLocations) let combinators = new Collection(useLocations) - let selectorNesting = new AggregateCollection() + let selectorNesting = new AggregateCollection(samples) let uniqueSelectorNesting = new Collection(useLocations) // Declarations let uniqueDeclarations = new Set() let totalDeclarations = 0 - let declarationComplexities = new AggregateCollection() + let declarationComplexities = new AggregateCollection(samples) let importantDeclarations = 0 let importantsInKeyframes = 0 let importantCustomProperties = new Collection(useLocations) - let declarationNesting = new AggregateCollection() + let declarationNesting = new AggregateCollection(samples) let uniqueDeclarationNesting = new Collection(useLocations) // Properties @@ -165,10 +202,10 @@ function analyzeInternal(css: string, options: Options, useLo let propertyHacks = new Collection(useLocations) let propertyVendorPrefixes = new Collection(useLocations) let customProperties = new Collection(useLocations) - let propertyComplexities = new AggregateCollection() + let propertyComplexities = new AggregateCollection(samples) // Values - let valueComplexities = new AggregateCollection() + let valueComplexities = new AggregateCollection(samples) let vendorPrefixedValues = new Collection(useLocations) let valueBrowserhacks = new Collection(useLocations) let displays = new Collection(useLocations) @@ -211,6 +248,8 @@ function analyzeInternal(css: string, options: Options, useLo // Count nodes and track nesting if (node.type === AT_RULE) { + if (!runAtrules) return SKIP + let atruleLoc = toLoc(node) atruleNesting.push(depth) uniqueAtruleNesting.p(depth, atruleLoc) @@ -304,7 +343,7 @@ function analyzeInternal(css: string, options: Options, useLo } else if (node.type === STYLE_RULE) { // Handle keyframe rules specially if (inKeyframes && node.prelude) { - if (node.prelude.type === SELECTOR_LIST && node.prelude.children.length > 0) { + if (runSelectors && runSelectors_keyframes && node.prelude.type === SELECTOR_LIST && node.prelude.children.length > 0) { for (let keyframe_selector of node.prelude.children) { keyframeSelectors.p(keyframe_selector.text, toLoc(keyframe_selector)) } @@ -312,7 +351,7 @@ function analyzeInternal(css: string, options: Options, useLo // Don't count keyframe rules as regular rules, but continue walking // children to count declarations inside keyframes // (Declarations are counted in the Declaration handler below) - } else { + } else if (runRules) { // Only count non-keyframe rules totalRules++ @@ -363,73 +402,102 @@ function analyzeInternal(css: string, options: Options, useLo return SKIP } + if (!runSelectors) return SKIP + let loc = toLoc(node) - selectorNesting.push(depth > 0 ? depth - 1 : 0) - uniqueSelectorNesting.p(depth > 0 ? depth - 1 : 0, loc) + if (runSelectors_nesting) { + selectorNesting.push(depth > 0 ? depth - 1 : 0) + uniqueSelectorNesting.p(depth > 0 ? depth - 1 : 0, loc) + } + uniqueSelectors.add(node.text) - let complexity = getComplexity(node) - selectorComplexities.push(complexity) - uniqueSelectorComplexities.p(complexity, loc) + if (runSelectors_complexity) { + let complexity = getComplexity(node) + selectorComplexities.push(complexity) + uniqueSelectorComplexities.p(complexity, loc) + } - isPrefixed(node, (prefix) => { - prefixedSelectors.p(prefix.toLowerCase(), loc) - }) + if (runSelectors_prefixed) { + isPrefixed(node, (prefix) => { + prefixedSelectors.p(prefix.toLowerCase(), loc) + }) + } - // Check for accessibility selectors - isAccessibility(node, (a11y_selector) => { - a11y.p(a11y_selector, loc) - }) + if (runSelectors_a11y) { + // Check for accessibility selectors + isAccessibility(node, (a11y_selector) => { + a11y.p(a11y_selector, loc) + }) + } - hasPseudoClass(node, (pseudo) => { - pseudoClasses.p(pseudo.toLowerCase(), loc) - }) + if (runSelectors_pseudoClasses) { + hasPseudoClass(node, (pseudo) => { + pseudoClasses.p(pseudo.toLowerCase(), loc) + }) + } - hasPseudoElement(node, (pseudo) => { - pseudoElements.p(pseudo.toLowerCase(), loc) - }) + if (runSelectors_pseudoElements) { + hasPseudoElement(node, (pseudo) => { + pseudoElements.p(pseudo.toLowerCase(), loc) + }) + } - walk(node, (child) => { - if (child.type === ATTRIBUTE_SELECTOR) { - attributeSelectors.p(child.name?.toLowerCase() ?? '', loc) - } - }) + if (runSelectors_attributes) { + walk(node, (child) => { + if (child.type === ATTRIBUTE_SELECTOR) { + attributeSelectors.p(child.name?.toLowerCase() ?? '', loc) + } + }) + } - getCombinators(node, (combinator) => { - let name = combinator.name.trim() === '' ? ' ' : combinator.name - combinators.p(name, combinator.loc) - }) + if (runSelectors_combinators) { + getCombinators(node, (combinator) => { + let name = combinator.name.trim() === '' ? ' ' : combinator.name + combinators.p(name, combinator.loc) + }) + } - let specificity = calculateSpecificity(node) - let [sa, sb, sc] = specificity + if (runSelectors_specificity) { + let specificity = calculateSpecificity(node) + let [sa, sb, sc] = specificity - uniqueSpecificities.p(specificity.toString(), loc) + uniqueSpecificities.p(specificity.toString(), loc) - specificityA.push(sa) - specificityB.push(sb) - specificityC.push(sc) + specificityA.push(sa) + specificityB.push(sb) + specificityC.push(sc) - if (maxSpecificity === undefined) { - maxSpecificity = specificity - } + if (maxSpecificity === undefined) { + maxSpecificity = specificity + } - if (minSpecificity === undefined) { - minSpecificity = specificity - } + if (minSpecificity === undefined) { + minSpecificity = specificity + } - if (minSpecificity !== undefined && compareSpecificity(minSpecificity, specificity) < 0) { - minSpecificity = specificity - } + if (minSpecificity !== undefined && compareSpecificity(minSpecificity, specificity) < 0) { + minSpecificity = specificity + } - if (maxSpecificity !== undefined && compareSpecificity(maxSpecificity, specificity) > 0) { - maxSpecificity = specificity - } + if (maxSpecificity !== undefined && compareSpecificity(maxSpecificity, specificity) > 0) { + maxSpecificity = specificity + } - specificities.push(specificity) + if (samples) { + specificities.push(specificity) + } - if (sa > 0) { - ids.p(node.text, loc) + if (runSelectors_id && sa > 0) { + ids.p(node.text, loc) + } + } else if (runSelectors_id) { + // Still need specificity for ID detection even if we're not running full specificity + let specificity = calculateSpecificity(node) + if (specificity[0] > 0) { + ids.p(node.text, loc) + } } // Avoid deeper walking of selectors to not mess with @@ -438,29 +506,38 @@ function analyzeInternal(css: string, options: Options, useLo // as children return SKIP } else if (node.type === DECLARATION) { + if (!runDeclarations && !runProperties && !runValues) return + totalDeclarations++ uniqueDeclarations.add(node.text) let loc = toLoc(node) let declarationDepth = depth > 0 ? depth - 1 : 0 - declarationNesting.push(declarationDepth) - uniqueDeclarationNesting.p(declarationDepth, loc) - - let complexity = 1 - if (node.is_important) { - complexity++ - let declaration = node.text - if (!declaration.toLowerCase().includes('!important')) { - valueBrowserhacks.p('!ie', toLoc(node.value as CSSNode)) - } + if (runDeclarations) { + declarationNesting.push(declarationDepth) + uniqueDeclarationNesting.p(declarationDepth, loc) - if (inKeyframes) { - importantsInKeyframes++ + let complexity = 1 + if (node.is_important) { complexity++ + + let declaration = node.text + if (!declaration.toLowerCase().includes('!important')) { + if (runValues_browserhacks) valueBrowserhacks.p('!ie', toLoc(node.value as CSSNode)) + } + + if (inKeyframes) { + importantsInKeyframes++ + complexity++ + } } + declarationComplexities.push(complexity) + } + + if (node.is_important && runDeclarations) { + importantDeclarations++ } - declarationComplexities.push(complexity) //#region PROPERTIES let { is_important, property, is_browserhack, is_vendor_prefixed } = node @@ -471,31 +548,31 @@ function analyzeInternal(css: string, options: Options, useLo propertyLoc.length = property.length let normalizedProperty = basename(property) - properties.p(normalizedProperty, propertyLoc) - - if (is_important) { - importantDeclarations++ - } + if (runProperties) { + properties.p(normalizedProperty, propertyLoc) - // Count important declarations - if (is_vendor_prefixed) { - propertyComplexities.push(2) - propertyVendorPrefixes.p(property, propertyLoc) - } else if (is_custom(property)) { - customProperties.p(property, propertyLoc) - propertyComplexities.push(is_important ? 3 : 2) + // Count important declarations + if (is_vendor_prefixed) { + propertyComplexities.push(2) + propertyVendorPrefixes.p(property, propertyLoc) + } else if (is_custom(property)) { + customProperties.p(property, propertyLoc) + propertyComplexities.push(is_important ? 3 : 2) - if (is_important) { - importantCustomProperties.p(property, propertyLoc) + if (is_important) { + importantCustomProperties.p(property, propertyLoc) + } + } else if (is_browserhack) { + propertyHacks.p(property.charAt(0), propertyLoc) + propertyComplexities.push(2) + } else { + propertyComplexities.push(1) } - } else if (is_browserhack) { - propertyHacks.p(property.charAt(0), propertyLoc) - propertyComplexities.push(2) - } else { - propertyComplexities.push(1) } //#endregion PROPERTIES + if (!runValues) return + //#region VALUES // Values are analyzed inside declaration because we need context, like which property is used { @@ -507,10 +584,10 @@ function analyzeInternal(css: string, options: Options, useLo // auto, inherit, initial, none, etc. if (keywords.has(text)) { - valueKeywords.p(text.toLowerCase(), valueLoc) + if (runValues_keywords) valueKeywords.p(text.toLowerCase(), valueLoc) valueComplexities.push(complexity) - if (normalizedProperty === 'display') { + if (runValues_displays && normalizedProperty === 'display') { displays.p(text.toLowerCase(), valueLoc) } @@ -519,14 +596,20 @@ function analyzeInternal(css: string, options: Options, useLo //#region VALUE COMPLEXITY // i.e. `background-image: -webkit-linear-gradient()` - isValuePrefixed(value, (prefixed) => { - vendorPrefixedValues.p(prefixed.toLowerCase(), valueLoc) - complexity++ - }) + if (runValues_prefixes) { + isValuePrefixed(value, (prefixed) => { + vendorPrefixedValues.p(prefixed.toLowerCase(), valueLoc) + complexity++ + }) + } else { + isValuePrefixed(value, () => { + complexity++ + }) + } // i.e. `property: value\9` if (isIe9Hack(value)) { - valueBrowserhacks.p('\\9', valueLoc) + if (runValues_browserhacks) valueBrowserhacks.p('\\9', valueLoc) text = text.slice(0, -2) complexity++ } @@ -537,23 +620,23 @@ function analyzeInternal(css: string, options: Options, useLo // Process properties first that don't have colors, // so we can avoid further walking them; - if (SPACING_RESET_PROPERTIES.has(normalizedProperty)) { + if (runValues_resets && SPACING_RESET_PROPERTIES.has(normalizedProperty)) { if (isValueReset(value)) { resets.p(normalizedProperty, valueLoc) } - } else if (normalizedProperty === 'display') { + } else if (runValues_displays && normalizedProperty === 'display') { if (/var\(/i.test(text)) { displays.p(text, valueLoc) } else { displays.p(text.toLowerCase(), valueLoc) } - } else if (normalizedProperty === 'z-index') { + } else if (runValues_zindexes && normalizedProperty === 'z-index') { zindex.p(text, valueLoc) return SKIP } else if (normalizedProperty === 'font') { if (!SYSTEM_FONTS.has(text)) { let result = destructure(value, function (item) { - if (item.type === 'keyword') { + if (item.type === 'keyword' && runValues_keywords) { valueKeywords.p(item.value.toLowerCase(), valueLoc) } }) @@ -563,21 +646,21 @@ function analyzeInternal(css: string, options: Options, useLo } let { font_size, line_height, font_family } = result - if (font_family) { + if (font_family && runValues_fontFamilies) { fontFamilies.p(font_family, valueLoc) } - if (font_size) { + if (font_size && runValues_fontSizes) { fontSizes.p(font_size.toLowerCase(), valueLoc) } - if (line_height) { + if (line_height && runValues_lineHeights) { 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 (normalizedProperty === 'font-size') { + } else if (runValues_fontSizes && normalizedProperty === 'font-size') { if (!SYSTEM_FONTS.has(text)) { let normalized = text.toLowerCase() if (normalized.includes('var(')) { @@ -587,29 +670,29 @@ function analyzeInternal(css: string, options: Options, useLo } } } else if (normalizedProperty === 'font-family') { - if (!SYSTEM_FONTS.has(text)) { + if (runValues_fontFamilies && !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 (normalizedProperty === 'line-height') { + } else if (runValues_lineHeights && 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') { + } else if (runValues_animations && (normalizedProperty === 'transition' || normalizedProperty === 'animation')) { analyzeAnimation(value.children, function (item) { if (item.type === 'fn') { timingFunctions.p(item.value.text.toLowerCase(), valueLoc) } else if (item.type === 'duration') { durations.p(item.value.text.toLowerCase(), valueLoc) - } else if (item.type === 'keyword') { + } else if (item.type === 'keyword' && runValues_keywords) { valueKeywords.p(item.value.text.toLowerCase(), valueLoc) } }) return SKIP - } else if (normalizedProperty === 'animation-duration' || normalizedProperty === 'transition-duration') { + } else if (runValues_animations && (normalizedProperty === 'animation-duration' || normalizedProperty === 'transition-duration')) { for (let child of value.children) { if (child.type !== OPERATOR) { let text = child.text @@ -620,7 +703,7 @@ function analyzeInternal(css: string, options: Options, useLo } } } - } else if (normalizedProperty === 'transition-timing-function' || normalizedProperty === 'animation-timing-function') { + } else if (runValues_animations && (normalizedProperty === 'transition-timing-function' || normalizedProperty === 'animation-timing-function')) { for (let child of value.children) { if (child.type !== OPERATOR) { timingFunctions.p(child.text, valueLoc) @@ -634,11 +717,11 @@ function analyzeInternal(css: string, options: Options, useLo if (value.first_child?.type === IDENTIFIER) { containerNames.p(value.first_child.text, valueLoc) } - } else if (border_radius_properties.has(normalizedProperty)) { + } else if (runValues_borderRadiuses && border_radius_properties.has(normalizedProperty)) { borderRadiuses.push(text, property, valueLoc) - } else if (normalizedProperty === 'text-shadow') { + } else if (runValues_textShadows && normalizedProperty === 'text-shadow') { textShadows.p(text, valueLoc) - } else if (normalizedProperty === 'box-shadow') { + } else if (runValues_boxShadows && normalizedProperty === 'box-shadow') { boxShadows.p(text, valueLoc) } @@ -648,12 +731,14 @@ function analyzeInternal(css: string, options: Options, useLo walk(value, (valueNode) => { switch (valueNode.type) { case DIMENSION: { + if (!runValues_units) return SKIP let unit = valueNode.unit?.toLowerCase() ?? '' let loc = toLoc(valueNode) units.push(unit, property, loc) return SKIP } case HASH: { + if (!runValues_colors) return SKIP // Use text property for the hash value let hashText = valueNode.text if (!hashText || !hashText.startsWith('#')) { @@ -689,10 +774,12 @@ function analyzeInternal(css: string, options: Options, useLo return SKIP } - if (keywords.has(identifierText)) { + if (runValues_keywords && keywords.has(identifierText)) { valueKeywords.p(identifierText.toLowerCase(), identifierLoc) } + if (!runValues_colors) return SKIP + // Bail out if it can't be a color name // 20 === 'lightgoldenrodyellow'.length // 3 === 'red'.length @@ -729,15 +816,14 @@ function analyzeInternal(css: string, options: Options, useLo let funcLoc = toLoc(valueNode) // rgb(a), hsl(a), color(), hwb(), lch(), lab(), oklab(), oklch() - if (colorFunctions.has(funcName)) { + if (runValues_colors && colorFunctions.has(funcName)) { colors.push(valueNode.text, property, funcLoc) colorFormats.p(funcName.toLowerCase(), funcLoc) return } - if (endsWith('gradient', funcName)) { + if (runValues_gradients && endsWith('gradient', funcName)) { gradients.p(valueNode.text, funcLoc) - return } // No SKIP here intentionally, // otherwise we'll miss colors in linear-gradient(), var() fallbacks, etc. @@ -747,6 +833,8 @@ function analyzeInternal(css: string, options: Options, useLo } //#endregion VALUES } else if (node.type === URL) { + if (!runStylesheet) return + let { value } = node let embed = unquote((value as string) || '') if (str_starts_with(embed, 'data:')) { @@ -768,20 +856,20 @@ function analyzeInternal(css: string, options: Options, useLo item.count++ item.size += size embedTypes.unique.set(type, item) - if (useLocations && item.uniqueWithLocations) { - item.uniqueWithLocations.push(loc) + if (useLocations && item.locations) { + item.locations.push(loc) } } else { let item = { count: 1, size, - uniqueWithLocations: useLocations ? [loc] : undefined, + locations: useLocations ? [loc] : undefined, } embedTypes.unique.set(type, item) } } } else if (node.type === MEDIA_FEATURE) { - if (node.name) { + if (runAtrules && node.name) { mediaFeatures.p(node.name.toLowerCase(), toLoc(node)) } return SKIP @@ -805,7 +893,19 @@ function analyzeInternal(css: string, options: Options, useLo let valueComplexity = valueComplexities.aggregate() let atruleCount = atrules.c() - return { + // Build the embed types unique object without location data in the main result + let embedTypesUnique: Record = {} + for (let [type, item] of embedTypes.unique) { + embedTypesUnique[type] = { size: item.size, count: item.count } + } + + // Helper: conditionally include items[] based on samples option + function withSamples(base: T, items: number[]): T & { items?: number[] } { + if (samples) return assign({}, base, { items }) + return base + } + + let result = { stylesheet: { sourceLinesOfCode: atruleCount.total + totalSelectors + totalDeclarations + keyframeSelectors.size(), linesOfCode, @@ -824,24 +924,17 @@ function analyzeInternal(css: string, options: Options, useLo total: embedTypes.total, totalUnique: embedTypes.unique.size, uniquenessRatio: ratio(embedTypes.unique.size, embedTypes.total), - unique: Object.fromEntries(embedTypes.unique), + unique: embedTypesUnique, }, }, }, atrules: assign(atruleCount, { - fontface: assign( - { - total: fontFacesCount, - totalUnique: fontFacesCount, - unique: fontfaces, - uniquenessRatio: fontFacesCount === 0 ? 0 : 1, - }, - useLocations - ? { - uniqueWithLocations: fontfaces_with_loc.c().uniqueWithLocations, - } - : {}, - ), + fontface: { + total: fontFacesCount, + totalUnique: fontFacesCount, + unique: fontfaces, + uniquenessRatio: fontFacesCount === 0 ? 0 : 1, + }, import: imports.c(), media: assign(medias.c(), { browserhacks: mediaBrowserhacks.c(), @@ -865,9 +958,7 @@ function analyzeInternal(css: string, options: Options, useLo complexity: atRuleComplexity, nesting: assign( atruleNesting.aggregate(), - { - items: atruleNesting.toArray(), - }, + withSamples({}, atruleNesting.toArray()), uniqueAtruleNesting.c(), ), }), @@ -879,30 +970,22 @@ function analyzeInternal(css: string, options: Options, useLo }, sizes: assign( ruleSizes.aggregate(), - { - items: ruleSizes.toArray(), - }, + withSamples({}, ruleSizes.toArray()), uniqueRuleSize.c(), ), nesting: assign( ruleNesting.aggregate(), - { - items: ruleNesting.toArray(), - }, + withSamples({}, ruleNesting.toArray()), uniqueRuleNesting.c(), ), selectors: assign( selectorsPerRule.aggregate(), - { - items: selectorsPerRule.toArray(), - }, + withSamples({}, selectorsPerRule.toArray()), uniqueSelectorsPerRule.c(), ), declarations: assign( declarationsPerRule.aggregate(), - { - items: declarationsPerRule.toArray(), - }, + withSamples({}, declarationsPerRule.toArray()), uniqueDeclarationsPerRule.c(), ), }, @@ -922,19 +1005,14 @@ function analyzeInternal(css: string, options: Options, useLo mean: [specificitiesA.mean, specificitiesB.mean, specificitiesC.mean], /** @type Specificity */ mode: [specificitiesA.mode, specificitiesB.mode, specificitiesC.mode], - /** @type Specificity */ - items: specificities, + ...(samples ? { items: specificities } : {}), }, uniqueSpecificities.c(), ), - complexity: assign(selectorComplexity, uniqueSelectorComplexities.c(), { - items: selectorComplexities.toArray(), - }), + complexity: assign(selectorComplexity, uniqueSelectorComplexities.c(), withSamples({}, selectorComplexities.toArray())), nesting: assign( selectorNesting.aggregate(), - { - items: selectorNesting.toArray(), - }, + withSamples({}, selectorNesting.toArray()), uniqueSelectorNesting.c(), ), id: assign(ids.c(), { @@ -967,9 +1045,7 @@ function analyzeInternal(css: string, options: Options, useLo complexity: declarationComplexity, nesting: assign( declarationNesting.aggregate(), - { - items: declarationNesting.toArray(), - }, + withSamples({}, declarationNesting.toArray()), uniqueDeclarationNesting.c(), ), }, @@ -1018,6 +1094,89 @@ function analyzeInternal(css: string, options: Options, useLo total: Date.now() - start, }, } + + if (!useLocations) { + return result + } + + // Build the parallel locations map + let locations: Record = {} + + function addLocs(key: string, collection: Collection | ContextCollection) { + let locs = collection.locs() + if (locs) locations[key] = locs + } + + addLocs('atrules', atrules) + addLocs('atrules.fontface', fontfaces_with_loc) + addLocs('atrules.import', imports) + addLocs('atrules.media', medias) + addLocs('atrules.media.browserhacks', mediaBrowserhacks) + addLocs('atrules.media.features', mediaFeatures) + addLocs('atrules.charset', charsets) + addLocs('atrules.supports', supports) + addLocs('atrules.supports.browserhacks', supportsBrowserhacks) + addLocs('atrules.keyframes', keyframes) + addLocs('atrules.keyframes.prefixed', prefixedKeyframes) + addLocs('atrules.container', containers) + addLocs('atrules.container.names', containerNames) + addLocs('atrules.layer', layers) + addLocs('atrules.property', registeredProperties) + addLocs('atrules.scope', scopes) + addLocs('atrules.nesting', uniqueAtruleNesting) + addLocs('rules.sizes', uniqueRuleSize) + addLocs('rules.nesting', uniqueRuleNesting) + addLocs('rules.selectors', uniqueSelectorsPerRule) + addLocs('rules.declarations', uniqueDeclarationsPerRule) + addLocs('selectors.specificity', uniqueSpecificities) + addLocs('selectors.complexity', uniqueSelectorComplexities) + addLocs('selectors.nesting', uniqueSelectorNesting) + addLocs('selectors.id', ids) + addLocs('selectors.pseudoClasses', pseudoClasses) + addLocs('selectors.pseudoElements', pseudoElements) + addLocs('selectors.accessibility', a11y) + addLocs('selectors.attributes', attributeSelectors) + addLocs('selectors.keyframes', keyframeSelectors) + addLocs('selectors.prefixed', prefixedSelectors) + addLocs('selectors.combinators', combinators) + addLocs('declarations.nesting', uniqueDeclarationNesting) + addLocs('declarations.importants.custom', importantCustomProperties) + addLocs('properties', properties) + addLocs('properties.prefixed', propertyVendorPrefixes) + addLocs('properties.custom', customProperties) + addLocs('properties.browserhacks', propertyHacks) + addLocs('values.colors', colors) + addLocs('values.gradients', gradients) + addLocs('values.fontFamilies', fontFamilies) + addLocs('values.fontSizes', fontSizes) + addLocs('values.lineHeights', lineHeights) + addLocs('values.zindexes', zindex) + addLocs('values.textShadows', textShadows) + addLocs('values.boxShadows', boxShadows) + addLocs('values.borderRadiuses', borderRadiuses) + addLocs('values.animations.durations', durations) + addLocs('values.animations.timingFunctions', timingFunctions) + addLocs('values.prefixes', vendorPrefixedValues) + addLocs('values.browserhacks', valueBrowserhacks) + addLocs('values.units', units) + addLocs('values.keywords', valueKeywords) + addLocs('values.resets', resets) + addLocs('values.displays', displays) + + // Add embed location data if tracked + if (useLocations) { + let embedLocs: UniqueWithLocations = {} + for (let [type, item] of embedTypes.unique) { + if (item.locations) { + embedLocs[type] = item.locations + } + } + if (Object.keys(embedLocs).length > 0) { + locations['stylesheet.embeddedContent'] = embedLocs + } + } + + return assign(result, { locations }) } /** @@ -1059,3 +1218,5 @@ export { hasVendorPrefix } from './vendor-prefix.js' export { KeywordSet } from './keyword-set.js' export type { Location, UniqueWithLocations } from './collection.js' + +export { transfer } from './transfer.js' diff --git a/src/options.ts b/src/options.ts new file mode 100644 index 00000000..e6bf4fe2 --- /dev/null +++ b/src/options.ts @@ -0,0 +1,41 @@ +export interface Options { + /** + * Only analyze the specified metric groups. + * Supports dot-notation for sub-metrics, e.g. `['values.colors', 'selectors.specificity']`. + * When omitted, all metrics are analyzed. + */ + only?: string[] + + /** + * Include raw `items[]` arrays for aggregate metrics (nesting depths, rule sizes, etc.). + * Enables histogram / distribution analysis downstream. + * @default false + */ + samples?: boolean + + /** + * Return a parallel `locations` map alongside the main result. + * The map has the shape `Record>`. + * When false (default), the main result is smaller and contains counts only. + * @default false + */ + useLocations?: boolean +} + +/** + * Returns true when the given metric key should be analyzed given the `only` list. + * A key matches when: + * - `only` is empty / undefined (run everything) + * - an `only` entry equals the key exactly + * - an `only` entry is a child of the key (e.g. only=['values.colors'] → run 'values') + * - an `only` entry is a parent of the key (e.g. only=['values'] → run 'values.colors') + */ +export function shouldRun(only: string[] | undefined, key: string): boolean { + if (!only || only.length === 0) return true + for (const entry of only) { + if (entry === key) return true + if (key.startsWith(entry + '.')) return true + if (entry.startsWith(key + '.')) return true + } + return false +} diff --git a/src/properties/properties.test.ts b/src/properties/properties.test.ts index f94b515b..4c4cdf8b 100644 --- a/src/properties/properties.test.ts +++ b/src/properties/properties.test.ts @@ -270,7 +270,8 @@ test('reports locations correctly', () => { margin: 0; } ` - const actual = analyze(fixture, { useLocations: true }).properties.uniqueWithLocations + const result = analyze(fixture, { useLocations: true }) + const actual = result.locations['properties'] expect(actual).toEqual({ margin: [{ offset: 24, length: 6, column: 7, line: 3 }], diff --git a/src/rules/rules.test.ts b/src/rules/rules.test.ts index 6d62ba8e..c5ed5621 100644 --- a/src/rules/rules.test.ts +++ b/src/rules/rules.test.ts @@ -27,7 +27,6 @@ test('should handle CSS without rules', () => { mode: 0, range: 0, sum: 0, - items: [], unique: {}, total: 0, totalUnique: 0, @@ -40,7 +39,6 @@ test('should handle CSS without rules', () => { mode: 0, range: 0, sum: 0, - items: [], unique: {}, total: 0, totalUnique: 0, @@ -53,7 +51,6 @@ test('should handle CSS without rules', () => { mode: 0, range: 0, sum: 0, - items: [], unique: {}, total: 0, totalUnique: 0, @@ -66,7 +63,6 @@ test('should handle CSS without rules', () => { mode: 0, range: 0, sum: 0, - items: [], total: 0, totalUnique: 0, unique: {}, @@ -102,7 +98,7 @@ test('counts sizes of rules', () => { } } } - `).rules.sizes + `, { samples: true }).rules.sizes let expected = { min: 2, @@ -318,7 +314,7 @@ test('return a list of declaration counts per rule', () => { d {} } } - `).rules.declarations.items + `, { samples: true }).rules.declarations.items const expected = [1, 2, 3, 2, 0] expect(actual).toEqual(expected) }) @@ -349,7 +345,7 @@ test('return a list of selectors counts per rule', () => { } } } - `).rules.declarations.items + `, { samples: true }).rules.declarations.items const expected = [1, 2, 3, 2] expect(actual).toEqual(expected) }) @@ -456,7 +452,7 @@ test('tracks nesting depth', () => { } } ` - const actual = analyze(fixture).rules.nesting + const actual = analyze(fixture, { samples: true }).rules.nesting const expected = { min: 0, max: 2, diff --git a/src/selectors/complexity.test.ts b/src/selectors/complexity.test.ts index aaac5f26..b03e6b1d 100644 --- a/src/selectors/complexity.test.ts +++ b/src/selectors/complexity.test.ts @@ -21,7 +21,7 @@ const fixture = ` ` test('calculates complexity', () => { - const actual = analyze(fixture).selectors.complexity.items + const actual = analyze(fixture, { samples: true }).selectors.complexity.items const expected = [1, 3, 1, 3, 3, 3, 1, 1, 1, 2, 3, 52, 5, 5, 2, 4] expect(actual).toEqual(expected) diff --git a/src/selectors/pseudos.test.ts b/src/selectors/pseudos.test.ts index 53c29bc7..9f9aa507 100644 --- a/src/selectors/pseudos.test.ts +++ b/src/selectors/pseudos.test.ts @@ -46,12 +46,13 @@ describe('pseudo classes', () => { }) test('logs the whole parent selector when using locations', () => { - let actual = analyze( + let result = analyze( ` a:hover, a:lang(en) {}`, { useLocations: true }, - ).selectors.pseudoClasses.uniqueWithLocations + ) + let actual = result.locations['selectors.pseudoClasses'] let expected = { hover: [ { diff --git a/src/selectors/selectors.test.ts b/src/selectors/selectors.test.ts index 3ec3376b..ba0035cf 100644 --- a/src/selectors/selectors.test.ts +++ b/src/selectors/selectors.test.ts @@ -41,7 +41,6 @@ test('handles CSS without selectors', () => { max: [0, 0, 0], mean: [0, 0, 0], mode: [0, 0, 0], - items: [], unique: {}, total: 0, totalUnique: 0, @@ -58,7 +57,6 @@ test('handles CSS without selectors', () => { totalUnique: 0, unique: {}, uniquenessRatio: 0, - items: [], }, id: { total: 0, @@ -118,7 +116,6 @@ test('handles CSS without selectors', () => { mode: 0, range: 0, sum: 0, - items: [], total: 0, totalUnique: 0, unique: {}, @@ -144,7 +141,7 @@ test('have their complexity calculated', () => { } } ` - const actual = analyze(fixture).selectors.complexity.items + const actual = analyze(fixture, { samples: true }).selectors.complexity.items const expected = [1, 1] expect(actual).toEqual(expected) @@ -170,7 +167,7 @@ test('have their specificity calculated', () => { } } ` - const actual = analyze(fixture) + const actual = analyze(fixture, { samples: true }) const expected = [ [0, 0, 1], [0, 0, 0], @@ -294,7 +291,7 @@ test('handles emoji selectors', () => { const fixture = ` .💩 {} ` - const result = analyze(fixture) + const result = analyze(fixture, { samples: true }) const actual = result.selectors const expected = { @@ -398,6 +395,7 @@ test('handles emoji selectors', () => { expect(actual).toEqual(expected) }) + test('analyzes vendor prefixed selectors', () => { let actual = analyze(` input[type=text]::-webkit-input-placeholder { @@ -470,9 +468,8 @@ test('tracks combinator locations', () => { let result = analyze(css, { useLocations: true, }) - let actual = result.selectors.combinators - expect(actual.uniqueWithLocations).toEqual({ + expect(result.locations['selectors.combinators']).toEqual({ ' ': [ { line: 2, @@ -509,7 +506,7 @@ test('tracks combinator locations', () => { ], }) - let as_strings = actual.uniqueWithLocations![' '].map((loc) => css.substring(loc.offset, loc.offset + loc.length)) + let as_strings = result.locations['selectors.combinators'][' '].map((loc) => css.substring(loc.offset, loc.offset + loc.length)) expect(as_strings).toEqual([' ', `\n`, ' ', ' ']) }) @@ -541,7 +538,7 @@ test('tracks nesting depth', () => { } } ` - const actual = analyze(fixture).selectors.nesting + const actual = analyze(fixture, { samples: true }).selectors.nesting const expected = { min: 0, max: 2, @@ -578,7 +575,8 @@ test('Can keep track of selector locations if we ask it to do so', () => { } } ` - let actual = analyze(fixture, { useLocations: true }).selectors.complexity.uniqueWithLocations + let result = analyze(fixture, { useLocations: true }) + let actual = result.locations['selectors.complexity'] let expected = { '1': [ { diff --git a/src/selectors/specificity.test.ts b/src/selectors/specificity.test.ts index 1f39daff..f6e996e2 100644 --- a/src/selectors/specificity.test.ts +++ b/src/selectors/specificity.test.ts @@ -6,7 +6,7 @@ test('handles the universal selector', () => { * {} test * {} ` - const actual = analyze(fixture).selectors.specificity.items + const actual = analyze(fixture, { samples: true }).selectors.specificity.items const expected = [ [0, 0, 0], [0, 0, 1], @@ -25,7 +25,7 @@ test('handles ID selectors', () => { #s12:not(FOO) /* a=1 b=0 c=1 */ {} ` - const actual = analyze(fixture).selectors.specificity.items + const actual = analyze(fixture, { samples: true }).selectors.specificity.items const expected = [ [1, 0, 0], [5, 4, 2], @@ -42,7 +42,7 @@ test('handles class selectors', () => { .class.class {} ` - const actual = analyze(fixture).selectors.specificity.items + const actual = analyze(fixture, { samples: true }).selectors.specificity.items const expected = [ [0, 1, 0], [0, 2, 0], @@ -56,7 +56,7 @@ test('handles element selectors', () => { element element {} ` - const actual = analyze(fixture).selectors.specificity.items + const actual = analyze(fixture, { samples: true }).selectors.specificity.items const expected = [ [0, 0, 1], [0, 0, 2], @@ -75,7 +75,7 @@ test('handles the :not, :is and :has pseudo classes', () => { .foo :is(.bar, #baz) /* a=1 b=1 c=0 */ {} ` - const actual = analyze(fixture).selectors.specificity.items + const actual = analyze(fixture, { samples: true }).selectors.specificity.items const expected = [ [1, 0, 0], [1, 0, 1], @@ -108,7 +108,7 @@ test('handles attribute selectors', () => { [|att] {} ` - const actual = analyze(fixture).selectors.specificity.items + const actual = analyze(fixture, { samples: true }).selectors.specificity.items const expected = [ [0, 1, 0], [5, 4, 2], @@ -138,7 +138,7 @@ test('handles the :where pseudo class', () => { .qux:where(em, #foo#bar#baz) /* [0,1,0] only the .qux outside the :where() contributes to selector specificity. */ {} ` - const actual = analyze(fixture).selectors.specificity.items + const actual = analyze(fixture, { samples: true }).selectors.specificity.items const expected = [ [0, 1, 0] ] @@ -158,7 +158,7 @@ test('handles pseudo element selectors', () => { :nth-child(2n+1) {} ` - const actual = analyze(fixture).selectors.specificity.items + const actual = analyze(fixture, { samples: true }).selectors.specificity.items const expected = [ [0, 0, 2], [0, 0, 2], diff --git a/src/stylesheet/stylesheet.test.ts b/src/stylesheet/stylesheet.test.ts index b08d8a57..b5689ada 100644 --- a/src/stylesheet/stylesheet.test.ts +++ b/src/stylesheet/stylesheet.test.ts @@ -165,7 +165,8 @@ test('measures base64 contents - with locations', () => { } ` - const actual = analyze(fixture, { useLocations: true }).stylesheet.embeddedContent + const result = analyze(fixture, { useLocations: true }) + const actual = result.stylesheet.embeddedContent const expected = { size: { total: 2337, @@ -179,50 +180,54 @@ test('measures base64 contents - with locations', () => { 'image/gif': { count: 1, size: 310, - uniqueWithLocations: [ - { - line: 5, - column: 9, - offset: 90, - length: 315, - }, - ], }, 'image/svg+xml': { count: 4, size: 2027, - uniqueWithLocations: [ - { - line: 13, - column: 19, - offset: 584, - length: 464, - }, - { - line: 14, - column: 19, - offset: 1068, - length: 439, - }, - { - line: 19, - column: 25, - offset: 1594, - length: 862, - }, - { - line: 24, - column: 26, - offset: 2531, - length: 286, - }, - ], }, }, }, } expect(actual).toEqual(expected) + + const locs = result.locations['stylesheet.embeddedContent'] + expect(locs).toEqual({ + 'image/gif': [ + { + line: 5, + column: 9, + offset: 90, + length: 315, + }, + ], + 'image/svg+xml': [ + { + line: 13, + column: 19, + offset: 584, + length: 464, + }, + { + line: 14, + column: 19, + offset: 1068, + length: 439, + }, + { + line: 19, + column: 25, + offset: 1594, + length: 862, + }, + { + line: 24, + column: 26, + offset: 2531, + length: 286, + }, + ], + }) }) test('reports embed size correctly when there are duplicates', () => { diff --git a/src/transfer.ts b/src/transfer.ts new file mode 100644 index 00000000..667c504e --- /dev/null +++ b/src/transfer.ts @@ -0,0 +1,63 @@ +import type { Location } from './collection.js' + +export type TransferResult = { + /** Compact JSON string of the main metrics */ + json: string + /** + * Flat array of location tuples encoded as [line, column, offset, length, ...]. + * Each group of 4 numbers is one Location. + * Can be transferred zero-copy via postMessage as a transferable. + */ + locationBuffer: Uint32Array | null + /** Mapping from index in locationBuffer (divided by 4) to metric key + value */ + locationIndex: Array<{ key: string; value: string }> | null +} + +/** + * Packages an analysis result for efficient postMessage transfer. + * + * Usage: + * ```ts + * const { json, locationBuffer } = transfer(result) + * worker.postMessage({ json, locationBuffer }, locationBuffer ? [locationBuffer.buffer] : []) + * ``` + */ +export function transfer(result: Record): TransferResult { + // Extract locations map if present + let locationsMap = result.locations as Record> | undefined + + // Clone result without the locations field for the compact JSON + let mainResult: Record = {} + for (let key of Object.keys(result)) { + if (key !== 'locations') { + mainResult[key] = result[key] + } + } + + if (!locationsMap) { + return { + json: JSON.stringify(mainResult), + locationBuffer: null, + locationIndex: null, + } + } + + // Pack location data into a flat Uint32Array for zero-copy transfer + let locationIndex: Array<{ key: string; value: string }> = [] + let tuples: number[] = [] + + for (let [metricKey, valueMap] of Object.entries(locationsMap)) { + for (let [value, locs] of Object.entries(valueMap)) { + for (let loc of locs) { + locationIndex.push({ key: metricKey, value }) + tuples.push(loc.line, loc.column, loc.offset, loc.length) + } + } + } + + return { + json: JSON.stringify(mainResult), + locationBuffer: tuples.length > 0 ? new Uint32Array(tuples) : null, + locationIndex: locationIndex.length > 0 ? locationIndex : null, + } +} diff --git a/src/values/colors.test.ts b/src/values/colors.test.ts index 5545f3ac..239170f1 100644 --- a/src/values/colors.test.ts +++ b/src/values/colors.test.ts @@ -1059,9 +1059,9 @@ test('Lists locations when unstable flag is set', () => { background-color: red; } ` - let actual = analyze(css, { useLocations: true }) + let result = analyze(css, { useLocations: true }) - expect(actual.values.colors.uniqueWithLocations).toEqual({ + expect(result.locations['values.colors']).toEqual({ red: [ { line: 3,