diff --git a/src/v2/analyzers/atrule-all.ts b/src/v2/analyzers/atrule-all.ts new file mode 100644 index 0000000..f9cd9b2 --- /dev/null +++ b/src/v2/analyzers/atrule-all.ts @@ -0,0 +1,27 @@ +// Counts every at-rule by its normalized name (vendor prefix stripped). +// Produces the combined count that v9 tracks as `atrules.c()`. + +import { NODE_TYPES, is_atrule, type AnyNode } from '@projectwallace/css-parser' +import { basename } from '../../properties/property-utils.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../internals/count-collection.js' +import type { AnalyzerInstance } from '../core.js' + +export type AtruleAllOptions = { locations?: boolean } + +export function atruleAll( + options: AtruleAllOptions = {}, +): AnalyzerInstance { + const collection = new CountCollection(options.locations === true) + + return { + subscribes: [NODE_TYPES.AT_RULE], + visit(node: AnyNode): void { + if (!is_atrule(node)) return + const name = basename(node.name ?? '') + collection.add(name, node.line, node.column, node.start, node.length) + }, + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/analyzers/atrule-charsets.ts b/src/v2/analyzers/atrule-charsets.ts new file mode 100644 index 0000000..980f5d6 --- /dev/null +++ b/src/v2/analyzers/atrule-charsets.ts @@ -0,0 +1,26 @@ +import { NODE_TYPES, is_atrule, type AnyNode, type Atrule } from '@projectwallace/css-parser' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../internals/count-collection.js' +import type { AnalyzerInstance } from '../core.js' + +export type AtruleCharsetsOptions = { locations?: boolean } + +export function atruleCharsets( + options: AtruleCharsetsOptions = {}, +): AnalyzerInstance { + const collection = new CountCollection(options.locations === true) + + return { + subscribes: [NODE_TYPES.AT_RULE], + visit(node: AnyNode): void { + if (!is_atrule(node)) return + if ((node.name?.toLowerCase() ?? '') !== 'charset') return + const n = node as Atrule + if (n.has_prelude) { + collection.add(n.prelude.text.toLowerCase(), n.line, n.column, n.start, n.length) + } + }, + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/analyzers/atrule-containers.ts b/src/v2/analyzers/atrule-containers.ts new file mode 100644 index 0000000..ae9a1ac --- /dev/null +++ b/src/v2/analyzers/atrule-containers.ts @@ -0,0 +1,47 @@ +import { + NODE_TYPES, + is_atrule, + is_atrule_prelude, + is_container_query, + is_identifier, + type AnyNode, + type Atrule, +} from '@projectwallace/css-parser' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../internals/count-collection.js' +import type { AnalyzerInstance } from '../core.js' + +export type AtruleContainersOptions = { locations?: boolean } + +export type AtruleContainersResult = { + queries: CountResult | CountResultWithLocations + names: CountResult | CountResultWithLocations +} + +export function atruleContainers( + options: AtruleContainersOptions = {}, +): AnalyzerInstance { + const withLocations = options.locations === true + const queries = new CountCollection(withLocations) + const names = new CountCollection(withLocations) + + return { + subscribes: [NODE_TYPES.AT_RULE], + visit(node: AnyNode): void { + if (!is_atrule(node)) return + if ((node.name?.toLowerCase() ?? '') !== 'container') return + const n = node as Atrule + if (!n.has_prelude) return + queries.add(n.prelude.text, n.line, n.column, n.start, n.length) + + if (is_atrule_prelude(n.prelude) && is_container_query(n.prelude.first_child)) { + const cq = n.prelude.first_child + if (cq.first_child && is_identifier(cq.first_child)) { + names.add(cq.first_child.text, n.line, n.column, n.start, n.length) + } + } + }, + collect(): AtruleContainersResult { + return { queries: queries.collect(), names: names.collect() } + }, + } +} diff --git a/src/v2/analyzers/atrule-fontfaces.ts b/src/v2/analyzers/atrule-fontfaces.ts new file mode 100644 index 0000000..eaac2a6 --- /dev/null +++ b/src/v2/analyzers/atrule-fontfaces.ts @@ -0,0 +1,55 @@ +import { + NODE_TYPES, + is_atrule, + is_declaration, + type AnyNode, + type Atrule, +} from '@projectwallace/css-parser' +import type { AnalyzerInstance } from '../core.js' +import type { Location } from '../internals/location-store.js' + +export type FontFaceDescriptors = Record + +export type AtruleFontFacesResult = { + total: number + unique: FontFaceDescriptors[] + uniqueWithLocations?: Array<{ descriptors: FontFaceDescriptors; location: Location }> +} + +export type AtruleFontFacesOptions = { locations?: boolean } + +export function atruleFontFaces( + options: AtruleFontFacesOptions = {}, +): AnalyzerInstance { + const withLocations = options.locations === true + const items: Array<{ descriptors: FontFaceDescriptors; location: Location }> = [] + + return { + subscribes: [NODE_TYPES.AT_RULE], + visit(node: AnyNode): void { + if (!is_atrule(node)) return + if ((node.name?.toLowerCase() ?? '') !== 'font-face') return + const n = node as Atrule + const descriptors: FontFaceDescriptors = Object.create(null) + for (const child of n.block?.children ?? []) { + if (is_declaration(child) && child.value) { + descriptors[child.property] = child.value.text + } + } + items.push({ + descriptors, + location: { line: n.line, column: n.column, offset: n.start, length: n.length }, + }) + }, + collect(): AtruleFontFacesResult { + const base: AtruleFontFacesResult = { + total: items.length, + unique: items.map((i) => i.descriptors), + } + if (withLocations) { + base.uniqueWithLocations = items + } + return base + }, + } +} diff --git a/src/v2/analyzers/atrule-imports.ts b/src/v2/analyzers/atrule-imports.ts new file mode 100644 index 0000000..db0504c --- /dev/null +++ b/src/v2/analyzers/atrule-imports.ts @@ -0,0 +1,26 @@ +import { NODE_TYPES, is_atrule, type AnyNode, type Atrule } from '@projectwallace/css-parser' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../internals/count-collection.js' +import type { AnalyzerInstance } from '../core.js' + +export type AtruleImportsOptions = { locations?: boolean } + +export function atruleImports( + options: AtruleImportsOptions = {}, +): AnalyzerInstance { + const collection = new CountCollection(options.locations === true) + + return { + subscribes: [NODE_TYPES.AT_RULE], + visit(node: AnyNode): void { + if (!is_atrule(node)) return + if ((node.name?.toLowerCase() ?? '') !== 'import') return + const n = node as Atrule + if (n.has_prelude) { + collection.add(n.prelude.text, n.line, n.column, n.start, n.length) + } + }, + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/analyzers/atrule-keyframes.ts b/src/v2/analyzers/atrule-keyframes.ts new file mode 100644 index 0000000..a5a51c9 --- /dev/null +++ b/src/v2/analyzers/atrule-keyframes.ts @@ -0,0 +1,48 @@ +import { NODE_TYPES, is_atrule, type AnyNode, type Atrule } from '@projectwallace/css-parser' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../internals/count-collection.js' +import type { AnalyzerInstance } from '../core.js' + +export type AtruleKeyframesResult = { + total: number + totalUnique: number + uniquenessRatio: number + unique: Record + prefixed: CountResult | CountResultWithLocations + prefixedRatio: number + uniqueWithLocations?: Record +} + +export type AtruleKeyframesOptions = { locations?: boolean } + +export function atruleKeyframes( + options: AtruleKeyframesOptions = {}, +): AnalyzerInstance { + const withLocations = options.locations === true + const all = new CountCollection(withLocations) + const prefixed = new CountCollection(withLocations) + + return { + subscribes: [NODE_TYPES.AT_RULE], + visit(node: AnyNode): void { + if (!is_atrule(node)) return + const name = node.name?.toLowerCase() ?? '' + if (!name.endsWith('keyframes')) return + const n = node as Atrule + if (!n.has_prelude) return + const prelude = n.prelude.text + all.add(prelude, n.line, n.column, n.start, n.length) + if (n.is_vendor_prefixed) { + prefixed.add(`@${name} ${prelude}`, n.line, n.column, n.start, n.length) + } + }, + collect(): AtruleKeyframesResult { + const allResult = all.collect() + const prefixedResult = prefixed.collect() + return { + ...allResult, + prefixed: prefixedResult, + prefixedRatio: allResult.total === 0 ? 0 : prefixedResult.total / allResult.total, + } + }, + } +} diff --git a/src/v2/analyzers/atrule-layers.ts b/src/v2/analyzers/atrule-layers.ts new file mode 100644 index 0000000..0fa1e7b --- /dev/null +++ b/src/v2/analyzers/atrule-layers.ts @@ -0,0 +1,30 @@ +import { NODE_TYPES, is_atrule, type AnyNode, type Atrule } from '@projectwallace/css-parser' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../internals/count-collection.js' +import type { AnalyzerInstance } from '../core.js' + +export type AtruleLayersOptions = { locations?: boolean } + +export function atruleLayers( + options: AtruleLayersOptions = {}, +): AnalyzerInstance { + const collection = new CountCollection(options.locations === true) + + return { + subscribes: [NODE_TYPES.AT_RULE], + visit(node: AnyNode): void { + if (!is_atrule(node)) return + if ((node.name?.toLowerCase() ?? '') !== 'layer') return + const n = node as Atrule + if (!n.has_prelude) { + collection.add('', n.line, n.column, n.start, n.length) + } else { + for (const layer of n.prelude.text.split(',').map((s: string) => s.trim())) { + collection.add(layer, n.line, n.column, n.start, n.length) + } + } + }, + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/analyzers/atrule-media.ts b/src/v2/analyzers/atrule-media.ts new file mode 100644 index 0000000..0a8414e --- /dev/null +++ b/src/v2/analyzers/atrule-media.ts @@ -0,0 +1,39 @@ +// Full @media analyzer: query strings + browser hacks. +// For individual feature counts use the separate uniqueMediaFeatures analyzer. + +import { NODE_TYPES, is_atrule, type AnyNode, type Atrule } from '@projectwallace/css-parser' +import { isMediaBrowserhack } from '../../atrules/atrules.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../internals/count-collection.js' +import type { AnalyzerInstance } from '../core.js' + +export type AtruleMediaOptions = { locations?: boolean } + +export type AtruleMediaResult = { + queries: CountResult | CountResultWithLocations + browserhacks: CountResult | CountResultWithLocations +} + +export function atruleMedia( + options: AtruleMediaOptions = {}, +): AnalyzerInstance { + const withLocations = options.locations === true + const queries = new CountCollection(withLocations) + const hacks = new CountCollection(withLocations) + + return { + subscribes: [NODE_TYPES.AT_RULE], + visit(node: AnyNode): void { + if (!is_atrule(node)) return + if ((node.name?.toLowerCase() ?? '') !== 'media') return + const n = node as Atrule + if (!n.has_prelude) return + queries.add(n.prelude.text, n.line, n.column, n.start, n.length) + isMediaBrowserhack(n.prelude, (hack) => { + hacks.add(hack, n.line, n.column, n.start, n.length) + }) + }, + collect(): AtruleMediaResult { + return { queries: queries.collect(), browserhacks: hacks.collect() } + }, + } +} diff --git a/src/v2/analyzers/atrule-misc.ts b/src/v2/analyzers/atrule-misc.ts new file mode 100644 index 0000000..1d3cf0c --- /dev/null +++ b/src/v2/analyzers/atrule-misc.ts @@ -0,0 +1,62 @@ +// Miscellaneous at-rules: @property (registered custom properties), @function, @scope. +// Also tracks overall atrule complexity and nesting depth. + +import { NODE_TYPES, is_atrule, type AnyNode, type Atrule } from '@projectwallace/css-parser' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../internals/count-collection.js' +import { AggregateCollection, type AggregateResult } from '../internals/aggregate-collection.js' +import type { AnalyzerInstance, WalkContext } from '../core.js' + +export type AtruleMiscOptions = { locations?: boolean } + +export type AtruleMiscResult = { + registeredProperties: CountResult | CountResultWithLocations + functions: CountResult | CountResultWithLocations + scopes: CountResult | CountResultWithLocations + complexity: AggregateResult + nesting: AggregateResult +} + +export function atruleMisc(options: AtruleMiscOptions = {}): AnalyzerInstance { + const withLocations = options.locations === true + const registeredProperties = new CountCollection(withLocations) + const functions = new CountCollection(withLocations) + const scopes = new CountCollection(withLocations) + const complexity = new AggregateCollection() + const nesting = new AggregateCollection() + + return { + subscribes: [NODE_TYPES.AT_RULE], + visit(node: AnyNode, ctx: WalkContext): void { + if (!is_atrule(node)) return + const n = node as Atrule + const name = n.name?.toLowerCase() ?? '' + nesting.push(ctx.depth) + + if (!n.has_prelude) { + complexity.push(name === 'layer' ? 2 : 1) + return + } + + let c = 1 + if (name === 'property') { + registeredProperties.add(n.prelude.text, n.line, n.column, n.start, n.length) + } else if (name === 'function') { + const prelude = n.prelude.text + const fname = prelude.includes('(') ? prelude.slice(0, prelude.indexOf('(')).trim() : prelude.trim() + functions.add(fname, n.line, n.column, n.start, n.length) + } else if (name === 'scope') { + scopes.add(n.prelude.text, n.line, n.column, n.start, n.length) + } + complexity.push(c) + }, + collect(): AtruleMiscResult { + return { + registeredProperties: registeredProperties.collect(), + functions: functions.collect(), + scopes: scopes.collect(), + complexity: complexity.collect(), + nesting: nesting.collect(), + } + }, + } +} diff --git a/src/v2/analyzers/atrule-supports.ts b/src/v2/analyzers/atrule-supports.ts new file mode 100644 index 0000000..8b533bb --- /dev/null +++ b/src/v2/analyzers/atrule-supports.ts @@ -0,0 +1,36 @@ +import { NODE_TYPES, is_atrule, type AnyNode, type Atrule } from '@projectwallace/css-parser' +import { isSupportsBrowserhack } from '../../atrules/atrules.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../internals/count-collection.js' +import type { AnalyzerInstance } from '../core.js' + +export type AtruleSupportsOptions = { locations?: boolean } + +export type AtruleSupportsResult = { + queries: CountResult | CountResultWithLocations + browserhacks: CountResult | CountResultWithLocations +} + +export function atruleSupports( + options: AtruleSupportsOptions = {}, +): AnalyzerInstance { + const withLocations = options.locations === true + const queries = new CountCollection(withLocations) + const hacks = new CountCollection(withLocations) + + return { + subscribes: [NODE_TYPES.AT_RULE], + visit(node: AnyNode): void { + if (!is_atrule(node)) return + if ((node.name?.toLowerCase() ?? '') !== 'supports') return + const n = node as Atrule + if (!n.has_prelude) return + queries.add(n.prelude.text, n.line, n.column, n.start, n.length) + isSupportsBrowserhack(n.prelude, (hack) => { + hacks.add(hack, n.line, n.column, n.start, n.length) + }) + }, + collect(): AtruleSupportsResult { + return { queries: queries.collect(), browserhacks: hacks.collect() } + }, + } +} diff --git a/src/v2/analyzers/colors-context.ts b/src/v2/analyzers/colors-context.ts new file mode 100644 index 0000000..fa1bc4a --- /dev/null +++ b/src/v2/analyzers/colors-context.ts @@ -0,0 +1,95 @@ +// Context-aware color analyzer — like uniqueColors but also groups by property. +// Used by the compat layer to produce values.colors.itemsPerContext. + +import { + NODE_TYPES, + walk, + SKIP, + is_hash, + is_identifier, + is_function, + is_declaration, + type AnyNode, + type Declaration, +} from '@projectwallace/css-parser' +import { colorFunctions, colorKeywords, namedColors, systemColors } from '../../values/colors.js' +import { endsWith } from '../../string-utils.js' +import { + ContextCountCollection, + type ContextCountResult, + type ContextCountResultWithLocations, +} from '../internals/context-count-collection.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../internals/count-collection.js' +import type { AnalyzerInstance } from '../core.js' + +const SKIPS_COLOR_LOOKUP = new Set(['font', 'font-family']) + +export type ColorsContextOptions = { locations?: boolean } + +export type ColorsContextResult = (ContextCountResult | ContextCountResultWithLocations) & { + formats: CountResult | CountResultWithLocations +} + +export function colorsContext( + options: ColorsContextOptions = {}, +): AnalyzerInstance { + const withLocations = options.locations === true + const collection = new ContextCountCollection(withLocations) + const formats = new CountCollection(withLocations) + + return { + subscribes: [NODE_TYPES.DECLARATION], + + visit(node: AnyNode): void { + const decl = node as Declaration + if (!is_declaration(decl)) return + const value = decl.value + if (!value) return + const property = decl.property.toLowerCase() + if (SKIPS_COLOR_LOOKUP.has(property)) return + + walk(value, (vn) => { + if (is_hash(vn)) { + const text = vn.text + if (!text || text.charCodeAt(0) !== 35) return SKIP + const lc = text.toLowerCase() + collection.add(lc, property, vn.line, vn.column, vn.start, vn.length) + let hexLen = lc.length - 1 + if (endsWith('\\9', lc) || endsWith('\\7', lc)) hexLen -= 2 + formats.add(`hex${hexLen}`, vn.line, vn.column, vn.start, vn.length) + return SKIP + } + + if (is_identifier(vn)) { + const ident = vn.text + const len = ident.length + if (len < 3 || len > 20) return SKIP + if (colorKeywords.has(ident)) { + const lc = ident.toLowerCase() + collection.add(lc, property, vn.line, vn.column, vn.start, vn.length) + formats.add(lc, vn.line, vn.column, vn.start, vn.length) + } else if (namedColors.has(ident)) { + collection.add(ident.toLowerCase(), property, vn.line, vn.column, vn.start, vn.length) + formats.add('named', vn.line, vn.column, vn.start, vn.length) + } else if (systemColors.has(ident)) { + collection.add(ident.toLowerCase(), property, vn.line, vn.column, vn.start, vn.length) + formats.add('system', vn.line, vn.column, vn.start, vn.length) + } + return SKIP + } + + if (is_function(vn)) { + if (colorFunctions.has(vn.name)) { + collection.add(vn.text, property, vn.line, vn.column, vn.start, vn.length) + formats.add(vn.name.toLowerCase(), vn.line, vn.column, vn.start, vn.length) + return SKIP + } + } + }) + }, + + collect(): ColorsContextResult { + return { ...collection.collect(), formats: formats.collect() } + }, + } +} diff --git a/src/v2/analyzers/declarations-per-rule.ts b/src/v2/analyzers/declarations-per-rule.ts new file mode 100644 index 0000000..cb6f282 --- /dev/null +++ b/src/v2/analyzers/declarations-per-rule.ts @@ -0,0 +1,46 @@ +// Declarations-per-rule analyzer. +// +// Subscribes to STYLE_RULE nodes. For each rule, counts direct-child +// declarations in its block (nested rules' declarations are counted under +// their own rule, not the parent). + +import { NODE_TYPES, is_declaration, type AnyNode, type Rule } from '@projectwallace/css-parser' +import { + NumericCollection, + type NumericResult, + type NumericResultWithLocations, +} from '../internals/numeric-collection.js' +import type { AnalyzerInstance } from '../core.js' + +export type DeclarationsPerRuleOptions = { + locations?: boolean +} + +export function declarationsPerRule( + options: DeclarationsPerRuleOptions = {}, +): AnalyzerInstance { + const withLocations = options.locations === true + const collection = new NumericCollection(withLocations) + + return { + subscribes: [NODE_TYPES.STYLE_RULE], + + visit(node: AnyNode): void { + const rule = node as Rule + const block = rule.block + if (!block) return + + let count = 0 + const children = block.children + for (let i = 0; i < children.length; i++) { + if (is_declaration(children[i]!)) count++ + } + + collection.push(count, rule.line, rule.column, rule.start, rule.length) + }, + + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/analyzers/declarations.ts b/src/v2/analyzers/declarations.ts new file mode 100644 index 0000000..6a36671 --- /dev/null +++ b/src/v2/analyzers/declarations.ts @@ -0,0 +1,83 @@ +// Declaration-level analysis: total/unique counts, !important tracking, nesting, complexity. + +import { + NODE_TYPES, + is_declaration, + is_supports_declaration, + type AnyNode, + type Declaration, +} from '@projectwallace/css-parser' +import { AggregateCollection, type AggregateResult } from '../internals/aggregate-collection.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../internals/count-collection.js' +import type { AnalyzerInstance, WalkContext } from '../core.js' + +export type DeclarationsOptions = { locations?: boolean } + +export type DeclarationsResult = { + total: number + totalUnique: number + uniquenessRatio: number + importants: { + total: number + ratio: number + inKeyframes: { total: number; ratio: number } + } + complexity: AggregateResult + nesting: AggregateResult & { unique: CountResult | CountResultWithLocations } +} + +export function declarations(options: DeclarationsOptions = {}): AnalyzerInstance { + const withLocations = options.locations === true + let total = 0 + const uniqueSet = new Set() + let importantTotal = 0 + let importantInKeyframes = 0 + const complexity = new AggregateCollection() + const nesting = new AggregateCollection() + const uniqueNesting = new CountCollection(withLocations) + + return { + subscribes: [NODE_TYPES.DECLARATION, NODE_TYPES.SUPPORTS_DECLARATION], + visit(node: AnyNode, ctx: WalkContext): void { + // Skip supports declarations (just guards, not real declarations) + if (is_supports_declaration(node)) return + if (!is_declaration(node)) return + + const decl = node as Declaration + total++ + uniqueSet.add(decl.text) + + const depth = ctx.depth > 0 ? ctx.depth - 1 : 0 + nesting.push(depth) + uniqueNesting.add(String(depth), decl.line, decl.column, decl.start, decl.length) + + let c = 1 + if (decl.is_important) { + c++ + importantTotal++ + if (ctx.inKeyframes) { + importantInKeyframes++ + c++ + } + } + complexity.push(c) + }, + collect(): DeclarationsResult { + return { + total, + totalUnique: uniqueSet.size, + uniquenessRatio: total === 0 ? 0 : uniqueSet.size / total, + importants: { + total: importantTotal, + ratio: total === 0 ? 0 : importantTotal / total, + inKeyframes: { + total: importantInKeyframes, + ratio: importantTotal === 0 ? 0 : importantInKeyframes / importantTotal, + }, + }, + complexity: complexity.collect(), + nesting: { ...nesting.collect(), unique: uniqueNesting.collect() }, + } + }, + } +} diff --git a/src/v2/analyzers/embedded-content.ts b/src/v2/analyzers/embedded-content.ts new file mode 100644 index 0000000..741f434 --- /dev/null +++ b/src/v2/analyzers/embedded-content.ts @@ -0,0 +1,108 @@ +// Embedded content analyzer. +// +// Subscribes to URL nodes, detects data URIs (data:…), and accumulates: +// - totalCount — total number of embedded data URIs +// - totalSize — total byte length of all data URIs combined +// - sizeRatio — totalSize / css.length (requires prepare to be called) +// - unique — per MIME-type count + size breakdown +// +// Locations (when enabled) point at the url() token for each occurrence. + +import { NODE_TYPES, str_starts_with, type AnyNode, type Url } from '@projectwallace/css-parser' +import { unquote } from '../../string-utils.js' +import { getEmbedType } from '../../stylesheet/stylesheet.js' +import { LocationStore, type Location } from '../internals/location-store.js' +import type { AnalyzerInstance } from '../core.js' + +type EmbedEntry = { + count: number + size: number + locs: LocationStore | null +} + +export type EmbedTypeResult = { + count: number + size: number +} + +export type EmbedTypeResultWithLocations = EmbedTypeResult & { + locations: Location[] +} + +export type EmbeddedContentResult = { + totalCount: number + totalSize: number + sizeRatio: number + unique: Record +} + +export type EmbeddedContentResultWithLocations = Omit & { + unique: Record +} + +export type EmbeddedContentOptions = { + locations?: boolean +} + +export function embeddedContent( + options: EmbeddedContentOptions = {}, +): AnalyzerInstance { + const withLocations = options.locations === true + let cssSize = 0 + let totalCount = 0 + let totalSize = 0 + const byType = new Map() + + return { + subscribes: [NODE_TYPES.URL], + + prepare(css: string): void { + cssSize = css.length + }, + + visit(node: AnyNode): void { + const url = node as Url + const raw = unquote(url.value ?? '') + if (!str_starts_with(raw, 'data:')) return + + const size = raw.length + const type = getEmbedType(raw) + totalCount++ + totalSize += size + + let entry = byType.get(type) + if (!entry) { + entry = { count: 0, size: 0, locs: withLocations ? new LocationStore() : null } + byType.set(type, entry) + } + entry.count++ + entry.size += size + if (withLocations && entry.locs !== null) { + entry.locs.push(url.line, url.column, url.start, url.length) + } + }, + + collect(): EmbeddedContentResult | EmbeddedContentResultWithLocations { + const unique: Record = {} + const uniqueWithLocations: Record = {} + + for (const [type, entry] of byType) { + unique[type] = { count: entry.count, size: entry.size } + if (withLocations && entry.locs !== null) { + uniqueWithLocations[type] = { + count: entry.count, + size: entry.size, + locations: entry.locs.toArray(), + } + } + } + + const sizeRatio = cssSize === 0 ? 0 : totalSize / cssSize + + if (withLocations) { + return { totalCount, totalSize, sizeRatio, unique: uniqueWithLocations } + } + return { totalCount, totalSize, sizeRatio, unique } + }, + } +} diff --git a/src/v2/analyzers/lines-of-code.ts b/src/v2/analyzers/lines-of-code.ts new file mode 100644 index 0000000..ac204ae --- /dev/null +++ b/src/v2/analyzers/lines-of-code.ts @@ -0,0 +1,34 @@ +// Lines-of-code analyzer. +// +// Operates on the raw CSS string (via prepare), not AST nodes. +// A "line" is any sequence terminated by \n — the last line counts even if +// it has no trailing newline. + +import type { AnyNode } from '@projectwallace/css-parser' +import type { AnalyzerInstance } from '../core.js' + +export type LinesOfCodeResult = { + total: number +} + +export function linesOfCode(): AnalyzerInstance { + let total = 0 + + return { + subscribes: [], + + prepare(css: string): void { + let count = 1 + for (let i = 0; i < css.length; i++) { + if (css.charCodeAt(i) === 10 /* \n */) count++ + } + total = count + }, + + visit(_node: AnyNode): void {}, + + collect(): LinesOfCodeResult { + return { total } + }, + } +} diff --git a/src/v2/analyzers/properties.ts b/src/v2/analyzers/properties.ts new file mode 100644 index 0000000..81cd199 --- /dev/null +++ b/src/v2/analyzers/properties.ts @@ -0,0 +1,115 @@ +// Property-level analysis: total/unique, vendor-prefixed, custom properties, +// shorthands, browser hacks, and complexity. + +import { + NODE_TYPES, + is_declaration, + is_custom, + is_supports_declaration, + type AnyNode, + type Declaration, +} from '@projectwallace/css-parser' +import { + basename, + shorthand_properties, + isHack, +} from '../../properties/property-utils.js' +import { AggregateCollection, type AggregateResult } from '../internals/aggregate-collection.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../internals/count-collection.js' +import type { AnalyzerInstance } from '../core.js' + +export type PropertiesOptions = { locations?: boolean } + +export type PropertiesResult = { + total: number + totalUnique: number + unique: Record + uniquenessRatio: number + prefixed: (CountResult | CountResultWithLocations) & { ratio: number } + custom: (CountResult | CountResultWithLocations) & { + ratio: number + importants: (CountResult | CountResultWithLocations) & { ratio: number } + } + shorthands: (CountResult | CountResultWithLocations) & { ratio: number } + browserhacks: (CountResult | CountResultWithLocations) & { ratio: number } + complexity: AggregateResult +} + +export function properties(options: PropertiesOptions = {}): AnalyzerInstance { + const withLocations = options.locations === true + const all = new CountCollection(withLocations) + const prefixed = new CountCollection(withLocations) + const custom = new CountCollection(withLocations) + const customImportants = new CountCollection(withLocations) + const shorthands = new CountCollection(withLocations) + const hacks = new CountCollection(withLocations) + const complexity = new AggregateCollection() + + return { + subscribes: [NODE_TYPES.DECLARATION], + visit(node: AnyNode): void { + if (is_supports_declaration(node)) return + if (!is_declaration(node)) return + + const decl = node as Declaration + const { property, is_vendor_prefixed, is_browserhack, is_important } = decl + if (!property) return + + const normalized = basename(property) + const pLine = decl.line + const pCol = decl.column + const pOff = decl.start + const pLen = property.length + + all.add(normalized, pLine, pCol, pOff, pLen) + + if (is_vendor_prefixed) { + prefixed.add(property, pLine, pCol, pOff, pLen) + complexity.push(2) + } else if (is_custom(property)) { + custom.add(property, pLine, pCol, pOff, pLen) + complexity.push(is_important ? 3 : 2) + if (is_important) { + customImportants.add(property, pLine, pCol, pOff, pLen) + } + } else if (is_browserhack || isHack(property)) { + hacks.add(property.charAt(0), pLine, pCol, pOff, pLen) + complexity.push(2) + } else { + complexity.push(1) + } + + if (shorthand_properties.has(normalized)) { + shorthands.add(property, pLine, pCol, pOff, pLen) + } + }, + collect(): PropertiesResult { + const allResult = all.collect() + const prefixedResult = prefixed.collect() + const customResult = custom.collect() + const customImportantsResult = customImportants.collect() + const shorthandsResult = shorthands.collect() + const hacksResult = hacks.collect() + const t = allResult.total + + return { + total: t, + totalUnique: allResult.totalUnique, + unique: allResult.unique, + uniquenessRatio: allResult.uniquenessRatio, + prefixed: { ...prefixedResult, ratio: t === 0 ? 0 : prefixedResult.total / t }, + custom: { + ...customResult, + ratio: t === 0 ? 0 : customResult.total / t, + importants: { + ...customImportantsResult, + ratio: customResult.total === 0 ? 0 : customImportantsResult.total / customResult.total, + }, + }, + shorthands: { ...shorthandsResult, ratio: t === 0 ? 0 : shorthandsResult.total / t }, + browserhacks: { ...hacksResult, ratio: t === 0 ? 0 : hacksResult.total / t }, + complexity: complexity.collect(), + } + }, + } +} diff --git a/src/v2/analyzers/rules.ts b/src/v2/analyzers/rules.ts new file mode 100644 index 0000000..c3c644d --- /dev/null +++ b/src/v2/analyzers/rules.ts @@ -0,0 +1,80 @@ +// Rule-level analysis: total count, empty rules, sizes, nesting, selectors-per-rule. +// Note: declarations-per-rule has its own dedicated analyzer for finer tree-shaking. + +import { + NODE_TYPES, + is_rule, + is_selector_list, + is_selector, + is_declaration, + type AnyNode, + type Rule, +} from '@projectwallace/css-parser' +import { AggregateCollection, type AggregateResult } from '../internals/aggregate-collection.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../internals/count-collection.js' +import type { AnalyzerInstance, WalkContext } from '../core.js' + +export type RulesOptions = { locations?: boolean } + +export type RulesResult = { + total: number + empty: { total: number; ratio: number } + sizes: AggregateResult & { unique: CountResult | CountResultWithLocations } + nesting: AggregateResult & { unique: CountResult | CountResultWithLocations } + selectorsPerRule: AggregateResult & { unique: CountResult | CountResultWithLocations } +} + +export function rules(options: RulesOptions = {}): AnalyzerInstance { + const withLocations = options.locations === true + let total = 0 + let empty = 0 + const sizes = new AggregateCollection() + const uniqueSizes = new CountCollection(withLocations) + const nesting = new AggregateCollection() + const uniqueNesting = new CountCollection(withLocations) + const selectorsPerRule = new AggregateCollection() + const uniqueSelectorsPerRule = new CountCollection(withLocations) + + return { + subscribes: [NODE_TYPES.STYLE_RULE], + visit(node: AnyNode, ctx: WalkContext): void { + if (!is_rule(node) || ctx.inKeyframes) return + const rule = node as Rule + total++ + if (rule.block?.is_empty) empty++ + + let numSelectors = 0 + let numDeclarations = 0 + if (rule.has_prelude && is_selector_list(rule.prelude)) { + for (const s of rule.prelude) { + if (is_selector(s)) numSelectors++ + } + } + if (rule.block) { + for (const child of rule.block) { + if (is_declaration(child)) numDeclarations++ + } + } + + const size = numSelectors + numDeclarations + sizes.push(size) + uniqueSizes.add(String(size), rule.line, rule.column, rule.start, rule.length) + + nesting.push(ctx.depth) + uniqueNesting.add(String(ctx.depth), rule.line, rule.column, rule.start, rule.length) + + selectorsPerRule.push(numSelectors) + uniqueSelectorsPerRule.add(String(numSelectors), rule.line, rule.column, rule.start, rule.length) + }, + collect(): RulesResult { + const ratio = total === 0 ? 0 : empty / total + return { + total, + empty: { total: empty, ratio }, + sizes: { ...sizes.collect(), unique: uniqueSizes.collect() }, + nesting: { ...nesting.collect(), unique: uniqueNesting.collect() }, + selectorsPerRule: { ...selectorsPerRule.collect(), unique: uniqueSelectorsPerRule.collect() }, + } + }, + } +} diff --git a/src/v2/analyzers/selectors.ts b/src/v2/analyzers/selectors.ts new file mode 100644 index 0000000..7dccc1c --- /dev/null +++ b/src/v2/analyzers/selectors.ts @@ -0,0 +1,181 @@ +// Full selector analysis: specificity, complexity, ids, pseudo-classes/elements, +// attributes, custom elements, combinators, prefixed, accessibility, keyframe selectors. + +import { + NODE_TYPES, + is_selector, + is_selector_list, + is_attribute_selector, + is_type_selector, + is_pseudo_class_selector, + is_pseudo_element_selector, + walk, + SKIP, + type AnyNode, + type Selector, +} from '@projectwallace/css-parser' +import { calculateForAST as calculateSpecificity, compare as compareSpecificity } from '../../selectors/specificity.js' +import { getComplexity, isPrefixed, isAccessibility, getCombinators } from '../../selectors/utils.js' +import { AggregateCollection, type AggregateResult } from '../internals/aggregate-collection.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../internals/count-collection.js' +import type { AnalyzerInstance, WalkContext } from '../core.js' + +export type Specificity = [number, number, number] + +export type SpecificityStats = { + min: Specificity + max: Specificity + sum: Specificity + mean: Specificity + mode: Specificity + items: Specificity[] + unique: CountResult | CountResultWithLocations +} + +export type SelectorsResult = { + total: number + totalUnique: number + uniquenessRatio: number + specificity: SpecificityStats + complexity: AggregateResult & { unique: CountResult | CountResultWithLocations } + nesting: AggregateResult & { unique: CountResult | CountResultWithLocations } + id: (CountResult | CountResultWithLocations) & { ratio: number } + pseudoClasses: CountResult | CountResultWithLocations + pseudoElements: CountResult | CountResultWithLocations + accessibility: (CountResult | CountResultWithLocations) & { ratio: number } + attributes: CountResult | CountResultWithLocations + customElements: CountResult | CountResultWithLocations + keyframes: CountResult | CountResultWithLocations + prefixed: (CountResult | CountResultWithLocations) & { ratio: number } + combinators: CountResult | CountResultWithLocations +} + +export type SelectorsOptions = { locations?: boolean } + +export function selectors(options: SelectorsOptions = {}): AnalyzerInstance { + const withLocations = options.locations === true + const uniqueSet = new Set() + let total = 0 + + const specificityA = new AggregateCollection() + const specificityB = new AggregateCollection() + const specificityC = new AggregateCollection() + const uniqueSpecificities = new CountCollection(withLocations) + const specificityItems: Specificity[] = [] + let minSpec: Specificity | undefined + let maxSpec: Specificity | undefined + + const complexity = new AggregateCollection() + const uniqueComplexity = new CountCollection(withLocations) + const nesting = new AggregateCollection() + const uniqueNesting = new CountCollection(withLocations) + + const ids = new CountCollection(withLocations) + const pseudoClasses = new CountCollection(withLocations) + const pseudoElements = new CountCollection(withLocations) + const a11y = new CountCollection(withLocations) + const attributes = new CountCollection(withLocations) + const customElements = new CountCollection(withLocations) + const keyframeSelectors = new CountCollection(withLocations) + const prefixed = new CountCollection(withLocations) + const combinators = new CountCollection(withLocations) + + return { + subscribes: [NODE_TYPES.SELECTOR], + visit(node: AnyNode, ctx: WalkContext): void { + if (!is_selector(node)) return + const sel = node as Selector + const loc = { line: sel.line, col: sel.column, off: sel.start, len: sel.length } + + if (ctx.inKeyframes) { + keyframeSelectors.add(sel.text, loc.line, loc.col, loc.off, loc.len) + return + } + + total++ + uniqueSet.add(sel.text) + + const nestingDepth = ctx.depth > 0 ? ctx.depth - 1 : 0 + nesting.push(nestingDepth) + uniqueNesting.add(String(nestingDepth), loc.line, loc.col, loc.off, loc.len) + + const c = getComplexity(sel) + complexity.push(c) + uniqueComplexity.add(String(c), loc.line, loc.col, loc.off, loc.len) + + isPrefixed(sel, (prefix) => { + prefixed.add(prefix.toLowerCase(), loc.line, loc.col, loc.off, loc.len) + }) + isAccessibility(sel, (a11ySel) => { + a11y.add(a11ySel, loc.line, loc.col, loc.off, loc.len) + }) + + walk(sel, (child) => { + if (is_attribute_selector(child)) { + attributes.add(child.name.toLowerCase(), loc.line, loc.col, loc.off, loc.len) + } else if (is_type_selector(child) && !child.name.startsWith('--') && child.name.includes('-')) { + customElements.add(child.name.toLowerCase(), loc.line, loc.col, loc.off, loc.len) + } else if (is_pseudo_class_selector(child)) { + pseudoClasses.add(child.name.toLowerCase(), loc.line, loc.col, loc.off, loc.len) + } else if (is_pseudo_element_selector(child)) { + pseudoElements.add(child.name.toLowerCase(), loc.line, loc.col, loc.off, loc.len) + } + }) + + getCombinators(sel, ({ name, loc: cLoc }) => { + combinators.add(name, cLoc.line, cLoc.column, cLoc.offset, cLoc.length) + }) + + const spec = calculateSpecificity(sel) + const [sa, sb, sc] = spec + specificityA.push(sa) + specificityB.push(sb) + specificityC.push(sc) + uniqueSpecificities.add(spec.toString(), loc.line, loc.col, loc.off, loc.len) + specificityItems.push(spec) + + if (!minSpec || compareSpecificity(minSpec, spec) > 0) minSpec = spec + if (!maxSpec || compareSpecificity(maxSpec, spec) < 0) maxSpec = spec + + if (sa > 0) ids.add(sel.text, loc.line, loc.col, loc.off, loc.len) + + return SKIP + }, + collect(): SelectorsResult { + const zero: Specificity = [0, 0, 0] + const saAgg = specificityA.collect() + const sbAgg = specificityB.collect() + const scAgg = specificityC.collect() + + const idsResult = ids.collect() + const a11yResult = a11y.collect() + const prefixedResult = prefixed.collect() + + return { + total, + totalUnique: uniqueSet.size, + uniquenessRatio: total === 0 ? 0 : uniqueSet.size / total, + specificity: { + min: minSpec ?? zero, + max: maxSpec ?? zero, + sum: [saAgg.sum, sbAgg.sum, scAgg.sum], + mean: [saAgg.mean, sbAgg.mean, scAgg.mean], + mode: [saAgg.mode, sbAgg.mode, scAgg.mode], + items: specificityItems, + unique: uniqueSpecificities.collect(), + }, + complexity: { ...complexity.collect(), unique: uniqueComplexity.collect() }, + nesting: { ...nesting.collect(), unique: uniqueNesting.collect() }, + id: { ...idsResult, ratio: total === 0 ? 0 : idsResult.total / total }, + pseudoClasses: pseudoClasses.collect(), + pseudoElements: pseudoElements.collect(), + accessibility: { ...a11yResult, ratio: total === 0 ? 0 : a11yResult.total / total }, + attributes: attributes.collect(), + customElements: customElements.collect(), + keyframes: keyframeSelectors.collect(), + prefixed: { ...prefixedResult, ratio: total === 0 ? 0 : prefixedResult.total / total }, + combinators: combinators.collect(), + } + }, + } +} diff --git a/src/v2/analyzers/source-lines-of-code.ts b/src/v2/analyzers/source-lines-of-code.ts new file mode 100644 index 0000000..46884a0 --- /dev/null +++ b/src/v2/analyzers/source-lines-of-code.ts @@ -0,0 +1,58 @@ +// Source lines of code: counts nodes that represent a "logical line" in CSS. +// Mirrors v9: atrule count + non-keyframe selector count + declaration count + keyframe selector count. + +import { + NODE_TYPES, + is_atrule, + is_selector_list, + is_selector, + type AnyNode, + type Rule, +} from '@projectwallace/css-parser' +import type { AnalyzerInstance, WalkContext } from '../core.js' + +export type SourceLinesOfCodeResult = { + total: number +} + +export function sourceLinesOfCode(): AnalyzerInstance { + let count = 0 + + return { + subscribes: [NODE_TYPES.AT_RULE, NODE_TYPES.STYLE_RULE, NODE_TYPES.DECLARATION], + + visit(node: AnyNode, ctx: WalkContext): void { + if (is_atrule(node)) { + count++ + return + } + + // STYLE_RULE — count each selector in the selector list + if (node.type === NODE_TYPES.STYLE_RULE) { + const rule = node as Rule + if (ctx.inKeyframes) { + // Keyframe percentage selectors also count + if (rule.has_prelude && is_selector_list(rule.prelude)) { + for (const sel of rule.prelude) { + if (is_selector(sel)) count++ + } + } + } else { + if (rule.has_prelude && is_selector_list(rule.prelude)) { + for (const sel of rule.prelude) { + if (is_selector(sel)) count++ + } + } + } + return + } + + // DECLARATION + count++ + }, + + collect(): SourceLinesOfCodeResult { + return { total: count } + }, + } +} diff --git a/src/v2/analyzers/stylesheet-meta.ts b/src/v2/analyzers/stylesheet-meta.ts new file mode 100644 index 0000000..316535f --- /dev/null +++ b/src/v2/analyzers/stylesheet-meta.ts @@ -0,0 +1,41 @@ +// Stylesheet-level metadata: byte size and comment statistics. +// These are purely string/parse-level — no AST walking needed. + +import type { AnyNode } from '@projectwallace/css-parser' +import type { AnalyzerInstance } from '../core.js' + +export type StylesheetMetaResult = { + size: number + comments: { + total: number + size: number + } +} + +export function stylesheetMeta(): AnalyzerInstance { + let size = 0 + let commentCount = 0 + let commentSize = 0 + + return { + subscribes: [], + + prepare(css: string): void { + size = css.length + }, + + on_comment(info: { length: number }): void { + commentCount++ + commentSize += info.length + }, + + visit(_node: AnyNode): void {}, + + collect(): StylesheetMetaResult { + return { + size, + comments: { total: commentCount, size: commentSize }, + } + }, + } +} diff --git a/src/v2/analyzers/unique-colors.ts b/src/v2/analyzers/unique-colors.ts new file mode 100644 index 0000000..b6a9dd9 --- /dev/null +++ b/src/v2/analyzers/unique-colors.ts @@ -0,0 +1,89 @@ +// Unique colors analyzer. +// +// Subscribes to DECLARATION nodes only. For each declaration we re-walk the +// value subtree (a small, bounded walk) looking for color tokens: +// - HASH nodes starting with '#' (hex colors) +// - IDENTIFIER nodes matching named / system / keyword colors +// - FUNCTION nodes named rgb/rgba/hsl/hsla/hwb/lab/lch/oklab/oklch/color +// +// Property context guards false positives: identifiers inside font / font-family +// declarations are ignored (e.g. "Black" as a font family is not a color). + +import { + NODE_TYPES, + walk, + SKIP, + is_hash, + is_identifier, + is_function, + type AnyNode, + type Declaration, +} from '@projectwallace/css-parser' +import { colorFunctions, colorKeywords, namedColors, systemColors } from '../../values/colors.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../internals/count-collection.js' +import type { AnalyzerInstance } from '../core.js' + +const SKIPS_COLOR_LOOKUP = new Set(['font', 'font-family']) + +export type UniqueColorsOptions = { + locations?: boolean +} + +export function uniqueColors( + options: UniqueColorsOptions = {}, +): AnalyzerInstance { + const withLocations = options.locations === true + const collection = new CountCollection(withLocations) + + return { + subscribes: [NODE_TYPES.DECLARATION], + + visit(node: AnyNode): void { + const decl = node as Declaration + const value = decl.value + if (!value) return + + const property = decl.property.toLowerCase() + if (SKIPS_COLOR_LOOKUP.has(property)) return + + walk(value, (vn) => { + if (is_hash(vn)) { + const text = vn.text + if (text && text.charCodeAt(0) === 35 /* # */) { + collection.add(text.toLowerCase(), vn.line, vn.column, vn.start, vn.length) + } + return SKIP + } + + if (is_identifier(vn)) { + const ident = vn.text + const len = ident.length + // 3 === 'red'.length, 20 === 'lightgoldenrodyellow'.length + if (len < 3 || len > 20) return SKIP + + if ( + colorKeywords.has(ident) || + namedColors.has(ident) || + systemColors.has(ident) + ) { + collection.add(ident.toLowerCase(), vn.line, vn.column, vn.start, vn.length) + } + return SKIP + } + + if (is_function(vn)) { + if (colorFunctions.has(vn.name)) { + collection.add(vn.text, vn.line, vn.column, vn.start, vn.length) + return SKIP + } + // don't SKIP — colors can live inside gradients, var() fallbacks, etc. + } + return + }) + }, + + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/analyzers/unique-media-features.ts b/src/v2/analyzers/unique-media-features.ts new file mode 100644 index 0000000..d6e8420 --- /dev/null +++ b/src/v2/analyzers/unique-media-features.ts @@ -0,0 +1,33 @@ +// Unique media features analyzer. +// +// Subscribes to MEDIA_FEATURE nodes. The feature name (node.property) is +// the identifier before the colon — e.g. "min-width", "hover", "color". + +import { NODE_TYPES, type AnyNode, type MediaFeature } from '@projectwallace/css-parser' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../internals/count-collection.js' +import type { AnalyzerInstance } from '../core.js' + +export type UniqueMediaFeaturesOptions = { + locations?: boolean +} + +export function uniqueMediaFeatures( + options: UniqueMediaFeaturesOptions = {}, +): AnalyzerInstance { + const withLocations = options.locations === true + const collection = new CountCollection(withLocations) + + return { + subscribes: [NODE_TYPES.MEDIA_FEATURE], + + visit(node: AnyNode): void { + const mf = node as MediaFeature + const name = mf.property.toLowerCase() + collection.add(name, mf.line, mf.column, mf.start, mf.length) + }, + + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/analyzers/value-complexity.ts b/src/v2/analyzers/value-complexity.ts new file mode 100644 index 0000000..ee92a08 --- /dev/null +++ b/src/v2/analyzers/value-complexity.ts @@ -0,0 +1,38 @@ +// Per-declaration value complexity: 1 + vendor-prefixed-values count + browser-hacks count. +// Powers values.complexity and contributes to stylesheet.complexity in the compat layer. + +import { + NODE_TYPES, + is_declaration, + is_custom, + is_supports_declaration, + type AnyNode, + type Declaration, +} from '@projectwallace/css-parser' +import { isValuePrefixed } from '../../values/vendor-prefix.js' +import { isValueBrowserhack } from '../../values/browserhacks.js' +import { AggregateCollection, type AggregateResult } from '../internals/aggregate-collection.js' +import type { AnalyzerInstance } from '../core.js' + +export function valueComplexity(): AnalyzerInstance { + const agg = new AggregateCollection() + + return { + subscribes: [NODE_TYPES.DECLARATION], + visit(node: AnyNode): void { + if (is_supports_declaration(node)) return + if (!is_declaration(node)) return + const decl = node as Declaration + if (!decl.value) return + let c = 1 + isValuePrefixed(decl.value, () => { c++ }) + if (!is_custom(decl.property)) { + isValueBrowserhack(decl.value, () => { c++ }) + } + agg.push(c) + }, + collect() { + return agg.collect() + }, + } +} diff --git a/src/v2/analyzers/values/animations.ts b/src/v2/analyzers/values/animations.ts new file mode 100644 index 0000000..12ee97f --- /dev/null +++ b/src/v2/analyzers/values/animations.ts @@ -0,0 +1,66 @@ +import { + NODE_TYPES, + is_declaration, + is_operator, + type AnyNode, + type Declaration, +} from '@projectwallace/css-parser' +import { analyzeAnimation } from '../../../values/animations.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../../internals/count-collection.js' +import type { AnalyzerInstance } from '../../core.js' + +export type AnimationsOptions = { locations?: boolean } + +export type AnimationsResult = { + durations: CountResult | CountResultWithLocations + timingFunctions: CountResult | CountResultWithLocations +} + +export function animations(options: AnimationsOptions = {}): AnalyzerInstance { + const withLocations = options.locations === true + const durations = new CountCollection(withLocations) + const timingFunctions = new CountCollection(withLocations) + + return { + subscribes: [NODE_TYPES.DECLARATION], + visit(node: AnyNode): void { + if (!is_declaration(node)) return + const decl = node as Declaration + if (!decl.value) return + const prop = decl.property.toLowerCase() + const vl = decl.value + + if (prop === 'transition' || prop === 'animation') { + analyzeAnimation(vl, (item) => { + if (item.type === 'duration') { + durations.add(item.value.text.toLowerCase(), vl.line, vl.column, vl.start, vl.length) + } else if (item.type === 'fn') { + timingFunctions.add(item.value.text.toLowerCase(), vl.line, vl.column, vl.start, vl.length) + } + }) + } else if (prop === 'animation-duration' || prop === 'transition-duration') { + for (const child of vl.children) { + if (!is_operator(child)) { + const text = child.text + durations.add( + text.toLowerCase().includes('var(') ? text : text.toLowerCase(), + child.line, + child.column, + child.start, + child.length, + ) + } + } + } else if (prop === 'transition-timing-function' || prop === 'animation-timing-function') { + for (const child of vl.children) { + if (!is_operator(child)) { + timingFunctions.add(child.text, child.line, child.column, child.start, child.length) + } + } + } + }, + collect(): AnimationsResult { + return { durations: durations.collect(), timingFunctions: timingFunctions.collect() } + }, + } +} diff --git a/src/v2/analyzers/values/border-radii.ts b/src/v2/analyzers/values/border-radii.ts new file mode 100644 index 0000000..40cf788 --- /dev/null +++ b/src/v2/analyzers/values/border-radii.ts @@ -0,0 +1,32 @@ +import { NODE_TYPES, is_declaration, type AnyNode, type Declaration } from '@projectwallace/css-parser' +import { border_radius_properties } from '../../../properties/property-utils.js' +import { + ContextCountCollection, + type ContextCountResult, + type ContextCountResultWithLocations, +} from '../../internals/context-count-collection.js' +import type { AnalyzerInstance } from '../../core.js' + +export type BorderRadiiOptions = { locations?: boolean } + +export function borderRadii( + options: BorderRadiiOptions = {}, +): AnalyzerInstance { + const collection = new ContextCountCollection(options.locations === true) + + return { + subscribes: [NODE_TYPES.DECLARATION], + visit(node: AnyNode): void { + if (!is_declaration(node)) return + const decl = node as Declaration + if (!decl.value) return + const prop = decl.property.toLowerCase() + if (!border_radius_properties.has(prop)) return + const vl = decl.value + collection.add(vl.text, prop, vl.line, vl.column, vl.start, vl.length) + }, + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/analyzers/values/color-formats.ts b/src/v2/analyzers/values/color-formats.ts new file mode 100644 index 0000000..24071c4 --- /dev/null +++ b/src/v2/analyzers/values/color-formats.ts @@ -0,0 +1,68 @@ +// Tracks color format distribution: hex3/4/6/8, named, rgb, hsl, etc. +// Complements the uniqueColors analyzer which tracks unique color values. + +import { + NODE_TYPES, + is_declaration, + is_hash, + is_identifier, + is_function, + walk, + SKIP, + type AnyNode, + type Declaration, +} from '@projectwallace/css-parser' +import { colorFunctions, colorKeywords, namedColors, systemColors } from '../../../values/colors.js' +import { endsWith } from '../../../string-utils.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../../internals/count-collection.js' +import type { AnalyzerInstance } from '../../core.js' + +const SKIPS_COLOR_LOOKUP = new Set(['font', 'font-family']) + +export type ColorFormatsOptions = { locations?: boolean } + +export function colorFormats( + options: ColorFormatsOptions = {}, +): AnalyzerInstance { + const collection = new CountCollection(options.locations === true) + + return { + subscribes: [NODE_TYPES.DECLARATION], + visit(node: AnyNode): void { + if (!is_declaration(node)) return + const decl = node as Declaration + if (!decl.value || SKIPS_COLOR_LOOKUP.has(decl.property.toLowerCase())) return + + walk(decl.value, (vn) => { + if (is_hash(vn)) { + const text = vn.text + if (!text || text.charCodeAt(0) !== 35) return SKIP + let len = text.length - 1 + if (endsWith('\\9', text) || endsWith('\\7', text)) len -= 2 + collection.add(`hex${len}`, vn.line, vn.column, vn.start, vn.length) + return SKIP + } + if (is_identifier(vn)) { + const ident = vn.text + const l = ident.length + if (l < 3 || l > 20) return SKIP + if (colorKeywords.has(ident)) { + collection.add(ident.toLowerCase(), vn.line, vn.column, vn.start, vn.length) + } else if (namedColors.has(ident)) { + collection.add('named', vn.line, vn.column, vn.start, vn.length) + } else if (systemColors.has(ident)) { + collection.add('system', vn.line, vn.column, vn.start, vn.length) + } + return SKIP + } + if (is_function(vn) && colorFunctions.has(vn.name)) { + collection.add(vn.name.toLowerCase(), vn.line, vn.column, vn.start, vn.length) + return SKIP + } + }) + }, + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/analyzers/values/displays.ts b/src/v2/analyzers/values/displays.ts new file mode 100644 index 0000000..f025e14 --- /dev/null +++ b/src/v2/analyzers/values/displays.ts @@ -0,0 +1,27 @@ +import { NODE_TYPES, is_declaration, type AnyNode, type Declaration } from '@projectwallace/css-parser' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../../internals/count-collection.js' +import type { AnalyzerInstance } from '../../core.js' + +export type DisplaysOptions = { locations?: boolean } + +export function displays( + options: DisplaysOptions = {}, +): AnalyzerInstance { + const collection = new CountCollection(options.locations === true) + + return { + subscribes: [NODE_TYPES.DECLARATION], + visit(node: AnyNode): void { + if (!is_declaration(node)) return + const decl = node as Declaration + if (decl.property.toLowerCase() !== 'display') return + if (!decl.value) return + const vl = decl.value + const text = vl.text + collection.add(/var\(/i.test(text) ? text : text.toLowerCase(), vl.line, vl.column, vl.start, vl.length) + }, + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/analyzers/values/font-families.ts b/src/v2/analyzers/values/font-families.ts new file mode 100644 index 0000000..0db3cbc --- /dev/null +++ b/src/v2/analyzers/values/font-families.ts @@ -0,0 +1,45 @@ +import { + NODE_TYPES, + is_declaration, + is_raw, + type AnyNode, + type Declaration, +} from '@projectwallace/css-parser' +import { destructure, SYSTEM_FONTS } from '../../../values/destructure-font-shorthand.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../../internals/count-collection.js' +import type { AnalyzerInstance } from '../../core.js' + +export type FontFamiliesOptions = { locations?: boolean } + +export function fontFamilies( + options: FontFamiliesOptions = {}, +): AnalyzerInstance { + const collection = new CountCollection(options.locations === true) + + return { + subscribes: [NODE_TYPES.DECLARATION], + visit(node: AnyNode): void { + if (!is_declaration(node)) return + const decl = node as Declaration + if (!decl.value || is_raw(decl.value)) return + const prop = decl.property.toLowerCase() + const vl = decl.value + + if (prop === 'font-family') { + if (!SYSTEM_FONTS.has(vl.text)) { + collection.add(vl.text, vl.line, vl.column, vl.start, vl.length) + } + } else if (prop === 'font') { + if (!SYSTEM_FONTS.has(vl.text)) { + const result = destructure(vl, () => {}) + if (result?.font_family) { + collection.add(result.font_family, vl.line, vl.column, vl.start, vl.length) + } + } + } + }, + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/analyzers/values/font-sizes.ts b/src/v2/analyzers/values/font-sizes.ts new file mode 100644 index 0000000..9b8d8d3 --- /dev/null +++ b/src/v2/analyzers/values/font-sizes.ts @@ -0,0 +1,47 @@ +import { + NODE_TYPES, + is_declaration, + is_raw, + type AnyNode, + type Declaration, +} from '@projectwallace/css-parser' +import { destructure, SYSTEM_FONTS } from '../../../values/destructure-font-shorthand.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../../internals/count-collection.js' +import type { AnalyzerInstance } from '../../core.js' + +export type FontSizesOptions = { locations?: boolean } + +export function fontSizes( + options: FontSizesOptions = {}, +): AnalyzerInstance { + const collection = new CountCollection(options.locations === true) + + return { + subscribes: [NODE_TYPES.DECLARATION], + visit(node: AnyNode): void { + if (!is_declaration(node)) return + const decl = node as Declaration + if (!decl.value || is_raw(decl.value)) return + const prop = decl.property.toLowerCase() + const vl = decl.value + const text = vl.text + + if (prop === 'font-size') { + if (!SYSTEM_FONTS.has(text)) { + const norm = text.toLowerCase() + collection.add(norm.includes('var(') ? text : norm, vl.line, vl.column, vl.start, vl.length) + } + } else if (prop === 'font') { + if (!SYSTEM_FONTS.has(text)) { + const result = destructure(vl, () => {}) + if (result?.font_size) { + collection.add(result.font_size.toLowerCase(), vl.line, vl.column, vl.start, vl.length) + } + } + } + }, + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/analyzers/values/gradients.ts b/src/v2/analyzers/values/gradients.ts new file mode 100644 index 0000000..5ad858a --- /dev/null +++ b/src/v2/analyzers/values/gradients.ts @@ -0,0 +1,36 @@ +import { + NODE_TYPES, + is_declaration, + is_function, + walk, + type AnyNode, + type Declaration, +} from '@projectwallace/css-parser' +import { endsWith } from '../../../string-utils.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../../internals/count-collection.js' +import type { AnalyzerInstance } from '../../core.js' + +export type GradientsOptions = { locations?: boolean } + +export function gradients( + options: GradientsOptions = {}, +): AnalyzerInstance { + const collection = new CountCollection(options.locations === true) + + return { + subscribes: [NODE_TYPES.DECLARATION], + visit(node: AnyNode): void { + if (!is_declaration(node)) return + const decl = node as Declaration + if (!decl.value) return + walk(decl.value, (vn) => { + if (is_function(vn) && endsWith('gradient', vn.name)) { + collection.add(vn.text, vn.line, vn.column, vn.start, vn.length) + } + }) + }, + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/analyzers/values/keywords.ts b/src/v2/analyzers/values/keywords.ts new file mode 100644 index 0000000..2b934a3 --- /dev/null +++ b/src/v2/analyzers/values/keywords.ts @@ -0,0 +1,34 @@ +import { + NODE_TYPES, + is_declaration, + is_raw, + type AnyNode, + type Declaration, +} from '@projectwallace/css-parser' +import { keywords as cssKeywords } from '../../../values/values.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../../internals/count-collection.js' +import type { AnalyzerInstance } from '../../core.js' + +export type KeywordsOptions = { locations?: boolean } + +export function keywords( + options: KeywordsOptions = {}, +): AnalyzerInstance { + const collection = new CountCollection(options.locations === true) + + return { + subscribes: [NODE_TYPES.DECLARATION], + visit(node: AnyNode): void { + if (!is_declaration(node)) return + const decl = node as Declaration + if (!decl.value || is_raw(decl.value)) return + const vl = decl.value + if (cssKeywords.has(vl.text)) { + collection.add(vl.text.toLowerCase(), vl.line, vl.column, vl.start, vl.length) + } + }, + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/analyzers/values/line-heights.ts b/src/v2/analyzers/values/line-heights.ts new file mode 100644 index 0000000..75ea8c5 --- /dev/null +++ b/src/v2/analyzers/values/line-heights.ts @@ -0,0 +1,45 @@ +import { + NODE_TYPES, + is_declaration, + is_raw, + type AnyNode, + type Declaration, +} from '@projectwallace/css-parser' +import { destructure, SYSTEM_FONTS } from '../../../values/destructure-font-shorthand.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../../internals/count-collection.js' +import type { AnalyzerInstance } from '../../core.js' + +export type LineHeightsOptions = { locations?: boolean } + +export function lineHeights( + options: LineHeightsOptions = {}, +): AnalyzerInstance { + const collection = new CountCollection(options.locations === true) + + return { + subscribes: [NODE_TYPES.DECLARATION], + visit(node: AnyNode): void { + if (!is_declaration(node)) return + const decl = node as Declaration + if (!decl.value || is_raw(decl.value)) return + const prop = decl.property.toLowerCase() + const vl = decl.value + const text = vl.text + + if (prop === 'line-height') { + const norm = text.toLowerCase() + collection.add(norm.includes('var(') ? text : norm, vl.line, vl.column, vl.start, vl.length) + } else if (prop === 'font') { + if (!SYSTEM_FONTS.has(text)) { + const result = destructure(vl, () => {}) + if (result?.line_height) { + collection.add(result.line_height.toLowerCase(), vl.line, vl.column, vl.start, vl.length) + } + } + } + }, + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/analyzers/values/resets.ts b/src/v2/analyzers/values/resets.ts new file mode 100644 index 0000000..beee659 --- /dev/null +++ b/src/v2/analyzers/values/resets.ts @@ -0,0 +1,30 @@ +import { NODE_TYPES, is_declaration, type AnyNode, type Declaration } from '@projectwallace/css-parser' +import { SPACING_RESET_PROPERTIES } from '../../../properties/property-utils.js' +import { isValueReset } from '../../../values/values.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../../internals/count-collection.js' +import type { AnalyzerInstance } from '../../core.js' + +export type ResetsOptions = { locations?: boolean } + +export function resets( + options: ResetsOptions = {}, +): AnalyzerInstance { + const collection = new CountCollection(options.locations === true) + + return { + subscribes: [NODE_TYPES.DECLARATION], + visit(node: AnyNode): void { + if (!is_declaration(node)) return + const decl = node as Declaration + if (!decl.value) return + const prop = decl.property.toLowerCase() + if (!SPACING_RESET_PROPERTIES.has(prop)) return + if (isValueReset(decl.value)) { + collection.add(prop, decl.value.line, decl.value.column, decl.value.start, decl.value.length) + } + }, + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/analyzers/values/shadows.ts b/src/v2/analyzers/values/shadows.ts new file mode 100644 index 0000000..da4ce06 --- /dev/null +++ b/src/v2/analyzers/values/shadows.ts @@ -0,0 +1,35 @@ +import { NODE_TYPES, is_declaration, type AnyNode, type Declaration } from '@projectwallace/css-parser' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../../internals/count-collection.js' +import type { AnalyzerInstance } from '../../core.js' + +export type ShadowsOptions = { locations?: boolean } + +export type ShadowsResult = { + textShadows: CountResult | CountResultWithLocations + boxShadows: CountResult | CountResultWithLocations +} + +export function shadows(options: ShadowsOptions = {}): AnalyzerInstance { + const withLocations = options.locations === true + const text = new CountCollection(withLocations) + const box = new CountCollection(withLocations) + + return { + subscribes: [NODE_TYPES.DECLARATION], + visit(node: AnyNode): void { + if (!is_declaration(node)) return + const decl = node as Declaration + if (!decl.value) return + const prop = decl.property.toLowerCase() + const vl = decl.value + if (prop === 'text-shadow') { + text.add(vl.text, vl.line, vl.column, vl.start, vl.length) + } else if (prop === 'box-shadow') { + box.add(vl.text, vl.line, vl.column, vl.start, vl.length) + } + }, + collect(): ShadowsResult { + return { textShadows: text.collect(), boxShadows: box.collect() } + }, + } +} diff --git a/src/v2/analyzers/values/units.ts b/src/v2/analyzers/values/units.ts new file mode 100644 index 0000000..4a219b2 --- /dev/null +++ b/src/v2/analyzers/values/units.ts @@ -0,0 +1,43 @@ +import { + NODE_TYPES, + is_declaration, + is_dimension, + walk, + SKIP, + type AnyNode, + type Declaration, +} from '@projectwallace/css-parser' +import { + ContextCountCollection, + type ContextCountResult, + type ContextCountResultWithLocations, +} from '../../internals/context-count-collection.js' +import { basename } from '../../../properties/property-utils.js' +import type { AnalyzerInstance } from '../../core.js' + +export type UnitsOptions = { locations?: boolean } + +export function units( + options: UnitsOptions = {}, +): AnalyzerInstance { + const collection = new ContextCountCollection(options.locations === true) + + return { + subscribes: [NODE_TYPES.DECLARATION], + visit(node: AnyNode): void { + if (!is_declaration(node)) return + const decl = node as Declaration + if (!decl.value) return + const prop = basename(decl.property) + walk(decl.value, (vn) => { + if (is_dimension(vn)) { + collection.add(vn.unit.toLowerCase(), prop, vn.line, vn.column, vn.start, vn.length) + return SKIP + } + }) + }, + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/analyzers/values/value-browserhacks.ts b/src/v2/analyzers/values/value-browserhacks.ts new file mode 100644 index 0000000..51e5029 --- /dev/null +++ b/src/v2/analyzers/values/value-browserhacks.ts @@ -0,0 +1,34 @@ +import { + NODE_TYPES, + is_declaration, + is_custom, + type AnyNode, + type Declaration, +} from '@projectwallace/css-parser' +import { isValueBrowserhack } from '../../../values/browserhacks.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../../internals/count-collection.js' +import type { AnalyzerInstance } from '../../core.js' + +export type ValueBrowserhacksOptions = { locations?: boolean } + +export function valueBrowserhacks( + options: ValueBrowserhacksOptions = {}, +): AnalyzerInstance { + const collection = new CountCollection(options.locations === true) + + return { + subscribes: [NODE_TYPES.DECLARATION], + visit(node: AnyNode): void { + if (!is_declaration(node)) return + const decl = node as Declaration + if (!decl.value || is_custom(decl.property)) return + const vl = decl.value + isValueBrowserhack(vl, (hack) => { + collection.add(hack, vl.line, vl.column, vl.start, vl.length) + }) + }, + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/analyzers/values/vendor-prefixed-values.ts b/src/v2/analyzers/values/vendor-prefixed-values.ts new file mode 100644 index 0000000..4fac9ec --- /dev/null +++ b/src/v2/analyzers/values/vendor-prefixed-values.ts @@ -0,0 +1,33 @@ +import { + NODE_TYPES, + is_declaration, + type AnyNode, + type Declaration, +} from '@projectwallace/css-parser' +import { isValuePrefixed } from '../../../values/vendor-prefix.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../../internals/count-collection.js' +import type { AnalyzerInstance } from '../../core.js' + +export type VendorPrefixedValuesOptions = { locations?: boolean } + +export function vendorPrefixedValues( + options: VendorPrefixedValuesOptions = {}, +): AnalyzerInstance { + const collection = new CountCollection(options.locations === true) + + return { + subscribes: [NODE_TYPES.DECLARATION], + visit(node: AnyNode): void { + if (!is_declaration(node)) return + const decl = node as Declaration + if (!decl.value) return + const vl = decl.value + isValuePrefixed(vl, (prefixed) => { + collection.add(prefixed.toLowerCase(), vl.line, vl.column, vl.start, vl.length) + }) + }, + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/analyzers/values/z-indexes.ts b/src/v2/analyzers/values/z-indexes.ts new file mode 100644 index 0000000..b62d073 --- /dev/null +++ b/src/v2/analyzers/values/z-indexes.ts @@ -0,0 +1,25 @@ +import { NODE_TYPES, is_declaration, type AnyNode, type Declaration } from '@projectwallace/css-parser' +import { CountCollection, type CountResult, type CountResultWithLocations } from '../../internals/count-collection.js' +import type { AnalyzerInstance } from '../../core.js' + +export type ZIndexesOptions = { locations?: boolean } + +export function zIndexes( + options: ZIndexesOptions = {}, +): AnalyzerInstance { + const collection = new CountCollection(options.locations === true) + + return { + subscribes: [NODE_TYPES.DECLARATION], + visit(node: AnyNode): void { + if (!is_declaration(node)) return + const decl = node as Declaration + if (decl.property.toLowerCase() !== 'z-index') return + if (!decl.value) return + collection.add(decl.value.text, decl.value.line, decl.value.column, decl.value.start, decl.value.length) + }, + collect() { + return collection.collect() + }, + } +} diff --git a/src/v2/compat.test.ts b/src/v2/compat.test.ts new file mode 100644 index 0000000..670823f --- /dev/null +++ b/src/v2/compat.test.ts @@ -0,0 +1,688 @@ +import { test, expect, describe } from 'vitest' +import { + analyze, + compareSpecificity, + selectorComplexity, + isAccessibilitySelector, + isSelectorPrefixed, + isMediaBrowserhack, + isSupportsBrowserhack, + isPropertyHack, + isValuePrefixed, + isValueBrowserhack, + hasVendorPrefix, + cssKeywords, + KeywordSet, + // Color exports + namedColors, + systemColors, + colorFunctions, + colorKeywords, + type UniqueWithLocations, + type Location, + type Specificity, +} from './compat.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 "isValueBrowserhack" method', () => { + expect(typeof isValueBrowserhack).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, + }, + function: { + 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, + }, + customElements: { + 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, + }, + }, + shorthands: { + 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/v2/compat.ts b/src/v2/compat.ts new file mode 100644 index 0000000..9a961f1 --- /dev/null +++ b/src/v2/compat.ts @@ -0,0 +1,320 @@ +// Compatibility layer: runs the v2 pipeline and reassembles the v9 analyze() output shape. +// Drop-in replacement for `import { analyze } from './index.js'` in tests. + +import { createPipeline } from './core.js' +import { linesOfCode } from './analyzers/lines-of-code.js' +import { sourceLinesOfCode } from './analyzers/source-lines-of-code.js' +import { stylesheetMeta } from './analyzers/stylesheet-meta.js' +import { embeddedContent } from './analyzers/embedded-content.js' +import { atruleAll } from './analyzers/atrule-all.js' +import { atruleImports } from './analyzers/atrule-imports.js' +import { atruleCharsets } from './analyzers/atrule-charsets.js' +import { atruleLayers } from './analyzers/atrule-layers.js' +import { atruleFontFaces } from './analyzers/atrule-fontfaces.js' +import { atruleKeyframes } from './analyzers/atrule-keyframes.js' +import { atruleMedia } from './analyzers/atrule-media.js' +import { atruleSupports } from './analyzers/atrule-supports.js' +import { atruleContainers } from './analyzers/atrule-containers.js' +import { atruleMisc } from './analyzers/atrule-misc.js' +import { uniqueMediaFeatures } from './analyzers/unique-media-features.js' +import { rules } from './analyzers/rules.js' +import { declarationsPerRule } from './analyzers/declarations-per-rule.js' +import { selectors } from './analyzers/selectors.js' +import { declarations } from './analyzers/declarations.js' +import { properties } from './analyzers/properties.js' +import { colorsContext } from './analyzers/colors-context.js' +import { gradients } from './analyzers/values/gradients.js' +import { fontFamilies } from './analyzers/values/font-families.js' +import { fontSizes } from './analyzers/values/font-sizes.js' +import { lineHeights } from './analyzers/values/line-heights.js' +import { zIndexes } from './analyzers/values/z-indexes.js' +import { shadows } from './analyzers/values/shadows.js' +import { borderRadii } from './analyzers/values/border-radii.js' +import { animations } from './analyzers/values/animations.js' +import { units } from './analyzers/values/units.js' +import { keywords } from './analyzers/values/keywords.js' +import { resets } from './analyzers/values/resets.js' +import { displays } from './analyzers/values/displays.js' +import { vendorPrefixedValues } from './analyzers/values/vendor-prefixed-values.js' +import { valueBrowserhacks } from './analyzers/values/value-browserhacks.js' +import { valueComplexity } from './analyzers/value-complexity.js' +import type { CountResult } from './internals/count-collection.js' +import type { AggregateResult } from './internals/aggregate-collection.js' + +// Re-export all pure utility functions so callers can import from compat.ts directly. +export { compare as compareSpecificity, calculate as calculateSpecificity } from '../selectors/specificity.js' +export { + getComplexity as selectorComplexity, + isPrefixed as isSelectorPrefixed, + isAccessibility as isAccessibilitySelector, +} from '../selectors/utils.js' +export { isSupportsBrowserhack, isMediaBrowserhack } from '../atrules/atrules.js' +export { isHack as isPropertyHack } from '../properties/property-utils.js' +export { isValuePrefixed } from '../values/vendor-prefix.js' +export { isValueBrowserhack } from '../values/browserhacks.js' +export { colorFunctions, colorKeywords, namedColors, systemColors } from '../values/colors.js' +export { keywords as cssKeywords } from '../values/values.js' +export { hasVendorPrefix } from '../vendor-prefix.js' +export { KeywordSet } from '../keyword-set.js' +export type { UniqueWithLocations, Location } from '../collection.js' +export type { Specificity } from './analyzers/selectors.js' + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// Compute stats + unique-count map from a raw items array. +// Mirrors v9: assign(agg.aggregate(), { items }, collection.c()) +function statsFromItems(items: number[]) { + const len = items.length + if (len === 0) { + return { min: 0, max: 0, mean: 0, mode: 0, range: 0, sum: 0, items: [], total: 0, totalUnique: 0, unique: {}, uniquenessRatio: 0 } + } + const sorted = items.slice().sort((a, b) => a - b) + const min = sorted[0]! + const max = sorted[len - 1]! + const sum = items.reduce((a, b) => a + b, 0) + + const freq = new Map() + let maxF = -1, modeSum = 0, modeCount = 0 + for (const v of sorted) { + const f = (freq.get(v) ?? 0) + 1 + freq.set(v, f) + if (f > maxF) { maxF = f; modeSum = 0; modeCount = 0 } + if (f >= maxF) { modeCount++; modeSum += v } + } + + const unique: Record = {} + for (const [v, c] of freq) unique[String(v)] = c + + return { + min, max, mean: sum / len, mode: modeSum / modeCount, + range: max - min, sum, items, + total: len, totalUnique: freq.size, unique, uniquenessRatio: freq.size / len, + } +} + +// v9 shape: assign(agg.aggregate(), { items }, collection.c()) +// Flattens AggregateResult + CountResult into a single object. +function flatAggColl(agg: AggregateResult, coll: CountResult) { + const { min, max, mean, mode, range, sum, items } = agg + const { total, totalUnique, unique, uniquenessRatio } = coll + return { min, max, mean, mode, range, sum, items, total, totalUnique, unique, uniquenessRatio } +} + +// Strip to just the 6 stat fields — used for complexity objects in v9 that have no items/total. +function onlyStats({ min, max, mean, mode, range, sum }: AggregateResult) { + return { min, max, mean, mode, range, sum } +} + +// ─── Pipeline factory ──────────────────────────────────────────────────────── +// Fresh analyzer instances are created per call so state never leaks between runs. + +function makePipeline() { + return createPipeline({ + loc: linesOfCode(), + sloc: sourceLinesOfCode(), + meta: stylesheetMeta(), + embed: embeddedContent(), + atruleAll: atruleAll(), + atruleImports: atruleImports(), + atruleCharsets: atruleCharsets(), + atruleLayers: atruleLayers(), + atruleFontFaces: atruleFontFaces(), + atruleKeyframes: atruleKeyframes(), + atruleMedia: atruleMedia(), + atruleSupports: atruleSupports(), + atruleContainers: atruleContainers(), + atruleMisc: atruleMisc(), + mediaFeatures: uniqueMediaFeatures(), + rules: rules(), + declsPerRule: declarationsPerRule(), + sel: selectors(), + decls: declarations(), + props: properties(), + colors: colorsContext(), + grads: gradients(), + fontFamilies: fontFamilies(), + fontSizes: fontSizes(), + lineHeights: lineHeights(), + zIndexes: zIndexes(), + shadows: shadows(), + borderRadii: borderRadii(), + animations: animations(), + units: units(), + keywords: keywords(), + resets: resets(), + displays: displays(), + vpValues: vendorPrefixedValues(), + vbHacks: valueBrowserhacks(), + valueC: valueComplexity(), +}) +} + +export type Options = { useLocations?: boolean } + +export function analyze(css: string, _options: Options = {}) { + const start = Date.now() + const r = makePipeline().run(css) + const analyzeStart = start // parse happens inside pipeline.run; we can't split it cleanly + const end = Date.now() + + const atruleAllResult = r.atruleAll as CountResult + const kf = r.atruleKeyframes + const ruleDecls = statsFromItems((r.declsPerRule as { items: number[] }).items) + + const atComplexity = r.atruleMisc.complexity + const selComplexity = r.sel.complexity + const declComplexity = r.decls.complexity + const propComplexity = r.props.complexity + const valComplexity = r.valueC + + return { + stylesheet: { + sourceLinesOfCode: r.sloc.total, + linesOfCode: r.loc.total, + size: r.meta.size, + complexity: + atComplexity.sum + + selComplexity.sum + + declComplexity.sum + + propComplexity.sum + + valComplexity.sum, + comments: r.meta.comments, + embeddedContent: { + size: { + total: r.embed.totalSize, + ratio: r.embed.sizeRatio, + }, + types: { + total: r.embed.totalCount, + totalUnique: Object.keys(r.embed.unique).length, + uniquenessRatio: + r.embed.totalCount === 0 + ? 0 + : Object.keys(r.embed.unique).length / r.embed.totalCount, + unique: r.embed.unique, + }, + }, + }, + + atrules: { + ...atruleAllResult, + fontface: { + total: r.atruleFontFaces.total, + totalUnique: r.atruleFontFaces.total, + unique: r.atruleFontFaces.unique, + uniquenessRatio: r.atruleFontFaces.total === 0 ? 0 : 1, + }, + import: r.atruleImports, + media: { + ...(r.atruleMedia.queries as CountResult), + browserhacks: r.atruleMedia.browserhacks, + features: r.mediaFeatures, + }, + charset: r.atruleCharsets, + supports: { + ...(r.atruleSupports.queries as CountResult), + browserhacks: r.atruleSupports.browserhacks, + }, + keyframes: { + total: (kf as CountResult).total, + totalUnique: (kf as CountResult).totalUnique, + unique: (kf as CountResult).unique, + uniquenessRatio: (kf as CountResult).uniquenessRatio, + prefixed: { + ...(kf.prefixed as CountResult), + ratio: kf.prefixedRatio, + }, + }, + container: { + ...(r.atruleContainers.queries as CountResult), + names: r.atruleContainers.names, + }, + layer: r.atruleLayers, + property: r.atruleMisc.registeredProperties, + function: r.atruleMisc.functions, + scope: r.atruleMisc.scopes, + complexity: onlyStats(atComplexity), + nesting: statsFromItems(r.atruleMisc.nesting.items), + }, + + rules: { + total: r.rules.total, + empty: r.rules.empty, + sizes: flatAggColl(r.rules.sizes, r.rules.sizes.unique as CountResult), + nesting: flatAggColl(r.rules.nesting, r.rules.nesting.unique as CountResult), + selectors: flatAggColl(r.rules.selectorsPerRule, r.rules.selectorsPerRule.unique as CountResult), + declarations: ruleDecls, + }, + + selectors: { + total: r.sel.total, + totalUnique: r.sel.totalUnique, + uniquenessRatio: r.sel.uniquenessRatio, + specificity: { + ...r.sel.specificity, + ...(r.sel.specificity.unique as CountResult), + unique: (r.sel.specificity.unique as CountResult).unique, + }, + complexity: { + ...onlyStats(selComplexity), + items: selComplexity.items, + ...(selComplexity.unique as CountResult), + unique: (selComplexity.unique as CountResult).unique, + }, + nesting: flatAggColl(r.sel.nesting, r.sel.nesting.unique as CountResult), + id: r.sel.id, + pseudoClasses: r.sel.pseudoClasses, + pseudoElements: r.sel.pseudoElements, + accessibility: r.sel.accessibility, + attributes: r.sel.attributes, + customElements: r.sel.customElements, + keyframes: r.sel.keyframes, + prefixed: r.sel.prefixed, + combinators: r.sel.combinators, + }, + + declarations: { + total: r.decls.total, + totalUnique: r.decls.totalUnique, + uniquenessRatio: r.decls.uniquenessRatio, + importants: r.decls.importants, + complexity: onlyStats(declComplexity), + nesting: flatAggColl(r.decls.nesting, r.decls.nesting.unique as CountResult), + }, + + properties: { + ...(r.props as unknown as CountResult), + prefixed: r.props.prefixed, + custom: r.props.custom, + shorthands: r.props.shorthands, + browserhacks: r.props.browserhacks, + complexity: onlyStats(propComplexity), + }, + + values: { + colors: r.colors, + gradients: r.grads, + fontFamilies: r.fontFamilies, + fontSizes: r.fontSizes, + lineHeights: r.lineHeights, + zindexes: r.zIndexes, + textShadows: r.shadows.textShadows, + boxShadows: r.shadows.boxShadows, + borderRadiuses: r.borderRadii, + animations: r.animations, + prefixes: r.vpValues, + browserhacks: r.vbHacks, + units: r.units, + complexity: onlyStats(valComplexity), + keywords: r.keywords, + resets: r.resets, + displays: r.displays, + }, + + __meta__: { + parseTime: end - analyzeStart, + analyzeTime: end - analyzeStart, + total: end - start, + }, + } +} diff --git a/src/v2/core.ts b/src/v2/core.ts new file mode 100644 index 0000000..9efc44d --- /dev/null +++ b/src/v2/core.ts @@ -0,0 +1,91 @@ +// Composable analysis pipeline. +// +// Each analyzer declares which AST node types it subscribes to. The pipeline +// parses once, walks once, and dispatches each visited node to subscribed +// analyzers via a precomputed table keyed on the numeric node type. +// +// Walk context available to every visit() call: +// depth — rule nesting depth (as reported by the parser's walk function) +// inKeyframes — true when inside a @keyframes / @-webkit-keyframes block + +import { parse, walk, is_atrule, type AnyNode, type ParserOptions } from '@projectwallace/css-parser' + +export interface WalkContext { + readonly depth: number + readonly inKeyframes: boolean +} + +export interface AnalyzerInstance { + readonly subscribes: readonly number[] + /** Called with the raw CSS string before the AST walk. Use for string-level metrics. */ + prepare?(css: string): void + /** Called for each comment during parsing. */ + on_comment?(info: { length: number }): void + visit(node: AnyNode, ctx: WalkContext): void + collect(): TResult +} + +type Results>> = { + [K in keyof T]: ReturnType +} + +export function createPipeline>>(analyzers: T) { + // Precompute dispatch: node type → analyzers interested in it. + const dispatch = new Map[]>() + const prepareList: AnalyzerInstance[] = [] + const commentList: AnalyzerInstance[] = [] + + for (const key in analyzers) { + const inst = analyzers[key]! + for (const nt of inst.subscribes) { + let bucket = dispatch.get(nt) + if (!bucket) { + bucket = [] + dispatch.set(nt, bucket) + } + bucket.push(inst) + } + if (inst.prepare) prepareList.push(inst) + if (inst.on_comment) commentList.push(inst) + } + + return { + run(css: string): Results { + for (let i = 0; i < prepareList.length; i++) prepareList[i]!.prepare!(css) + + const parseOptions: ParserOptions = + commentList.length > 0 + ? { + on_comment(info) { + for (let i = 0; i < commentList.length; i++) commentList[i]!.on_comment!(info) + }, + } + : {} + + const ast = parse(css, parseOptions) + + let keyframesDepth = -1 + walk(ast, (node, depth) => { + if (keyframesDepth >= 0 && depth <= keyframesDepth) keyframesDepth = -1 + const inKeyframes = keyframesDepth >= 0 && depth > keyframesDepth + + if (is_atrule(node)) { + const name = node.name?.toLowerCase() ?? '' + if (name.endsWith('keyframes')) keyframesDepth = depth + } + + const ctx: WalkContext = { depth, inKeyframes } + const handlers = dispatch.get(node.type) + if (handlers !== undefined) { + for (let i = 0; i < handlers.length; i++) handlers[i]!.visit(node, ctx) + } + }) + + const out = {} as Results + for (const key in analyzers) { + ;(out as Record)[key] = analyzers[key]!.collect() + } + return out + }, + } +} diff --git a/src/v2/index.ts b/src/v2/index.ts new file mode 100644 index 0000000..2dc6037 --- /dev/null +++ b/src/v2/index.ts @@ -0,0 +1,77 @@ +// v2 — composable CSS analyzer pipeline +// Import only the analyzers you need; the bundler tree-shakes the rest. + +export { createPipeline, type AnalyzerInstance, type WalkContext } from './core.js' + +// ─── Collections ──────────────────────────────────────────────────────────── +export type { Location } from './internals/location-store.js' +export type { CountResult, CountResultWithLocations } from './internals/count-collection.js' +export type { NumericResult, NumericResultWithLocations } from './internals/numeric-collection.js' +export type { AggregateResult } from './internals/aggregate-collection.js' +export type { ContextCountResult, ContextCountResultWithLocations } from './internals/context-count-collection.js' + +// ─── Stylesheet ────────────────────────────────────────────────────────────── +export { linesOfCode, type LinesOfCodeResult } from './analyzers/lines-of-code.js' +export { sourceLinesOfCode, type SourceLinesOfCodeResult } from './analyzers/source-lines-of-code.js' +export { stylesheetMeta, type StylesheetMetaResult } from './analyzers/stylesheet-meta.js' + +// ─── Embedded content ──────────────────────────────────────────────────────── +export { + embeddedContent, + type EmbeddedContentOptions, + type EmbeddedContentResult, + type EmbeddedContentResultWithLocations, + type EmbedTypeResult, + type EmbedTypeResultWithLocations, +} from './analyzers/embedded-content.js' + +// ─── At-rules ──────────────────────────────────────────────────────────────── +export { atruleImports, type AtruleImportsOptions } from './analyzers/atrule-imports.js' +export { atruleCharsets, type AtruleCharsetsOptions } from './analyzers/atrule-charsets.js' +export { atruleLayers, type AtruleLayersOptions } from './analyzers/atrule-layers.js' +export { atruleFontFaces, type AtruleFontFacesOptions, type AtruleFontFacesResult } from './analyzers/atrule-fontfaces.js' +export { atruleKeyframes, type AtruleKeyframesOptions, type AtruleKeyframesResult } from './analyzers/atrule-keyframes.js' +export { atruleMedia, type AtruleMediaOptions, type AtruleMediaResult } from './analyzers/atrule-media.js' +export { atruleSupports, type AtruleSupportsOptions, type AtruleSupportsResult } from './analyzers/atrule-supports.js' +export { atruleContainers, type AtruleContainersOptions, type AtruleContainersResult } from './analyzers/atrule-containers.js' +export { atruleMisc, type AtruleMiscOptions, type AtruleMiscResult } from './analyzers/atrule-misc.js' + +// ─── Media features ────────────────────────────────────────────────────────── +export { uniqueMediaFeatures, type UniqueMediaFeaturesOptions } from './analyzers/unique-media-features.js' + +// ─── Rules ─────────────────────────────────────────────────────────────────── +export { rules, type RulesOptions, type RulesResult } from './analyzers/rules.js' +export { declarationsPerRule, type DeclarationsPerRuleOptions } from './analyzers/declarations-per-rule.js' + +// ─── Selectors ─────────────────────────────────────────────────────────────── +export { + selectors, + type SelectorsOptions, + type SelectorsResult, + type Specificity, + type SpecificityStats, +} from './analyzers/selectors.js' + +// ─── Declarations ──────────────────────────────────────────────────────────── +export { declarations, type DeclarationsOptions, type DeclarationsResult } from './analyzers/declarations.js' + +// ─── Properties ────────────────────────────────────────────────────────────── +export { properties, type PropertiesOptions, type PropertiesResult } from './analyzers/properties.js' + +// ─── Values ────────────────────────────────────────────────────────────────── +export { uniqueColors, type UniqueColorsOptions } from './analyzers/unique-colors.js' +export { colorFormats, type ColorFormatsOptions } from './analyzers/values/color-formats.js' +export { gradients, type GradientsOptions } from './analyzers/values/gradients.js' +export { fontFamilies, type FontFamiliesOptions } from './analyzers/values/font-families.js' +export { fontSizes, type FontSizesOptions } from './analyzers/values/font-sizes.js' +export { lineHeights, type LineHeightsOptions } from './analyzers/values/line-heights.js' +export { zIndexes, type ZIndexesOptions } from './analyzers/values/z-indexes.js' +export { shadows, type ShadowsOptions, type ShadowsResult } from './analyzers/values/shadows.js' +export { borderRadii, type BorderRadiiOptions } from './analyzers/values/border-radii.js' +export { animations, type AnimationsOptions, type AnimationsResult } from './analyzers/values/animations.js' +export { units, type UnitsOptions } from './analyzers/values/units.js' +export { keywords, type KeywordsOptions } from './analyzers/values/keywords.js' +export { resets, type ResetsOptions } from './analyzers/values/resets.js' +export { displays, type DisplaysOptions } from './analyzers/values/displays.js' +export { vendorPrefixedValues, type VendorPrefixedValuesOptions } from './analyzers/values/vendor-prefixed-values.js' +export { valueBrowserhacks, type ValueBrowserhacksOptions } from './analyzers/values/value-browserhacks.js' diff --git a/src/v2/internals/aggregate-collection.ts b/src/v2/internals/aggregate-collection.ts new file mode 100644 index 0000000..0fdbdeb --- /dev/null +++ b/src/v2/internals/aggregate-collection.ts @@ -0,0 +1,75 @@ +// Aggregate statistics over a stream of numbers. +// Values are buffered in a GrowableUint32Array; statistics are computed at collect() time. + +import { GrowableUint32Array } from './growable-u32.js' + +export type AggregateResult = { + total: number + sum: number + min: number + max: number + mean: number + mode: number + range: number + items: number[] +} + +function mode(sorted: number[]): number { + const len = sorted.length + if (len === 0) return 0 + let maxOccurrences = -1 + let maxCount = 0 + let sum = 0 + const freq = new Map() + for (let i = 0; i < len; i++) { + const v = sorted[i]! + const c = (freq.get(v) ?? 0) + 1 + freq.set(v, c) + if (c > maxOccurrences) { + maxOccurrences = c + maxCount = 0 + sum = 0 + } + if (c >= maxOccurrences) { + maxCount++ + sum += v + } + } + return sum / maxCount +} + +export class AggregateCollection { + private values = new GrowableUint32Array(64) + private sumValue = 0 + + push(value: number): void { + this.values.push(value) + this.sumValue += value + } + + get total(): number { + return this.values.length + } + + collect(): AggregateResult { + const view = this.values.view() + const len = view.length + if (len === 0) { + return { total: 0, sum: 0, min: 0, max: 0, mean: 0, mode: 0, range: 0, items: [] } + } + + const items = Array.from(view).sort((a, b) => a - b) + const min = items[0]! + const max = items[len - 1]! + return { + total: len, + sum: this.sumValue, + min, + max, + mean: this.sumValue / len, + mode: mode(items), + range: max - min, + items: Array.from(view), + } + } +} diff --git a/src/v2/internals/context-count-collection.ts b/src/v2/internals/context-count-collection.ts new file mode 100644 index 0000000..77dae3a --- /dev/null +++ b/src/v2/internals/context-count-collection.ts @@ -0,0 +1,53 @@ +// Like CountCollection but also groups items by a "context" string (e.g. property name). +// Used for: colors per property, units per property, font families, border radii per property. + +import { StringInterner } from './string-interner.js' +import { GrowableUint32Array } from './growable-u32.js' +import { LocationStore, type Location } from './location-store.js' +import { CountCollection, type CountResult, type CountResultWithLocations } from './count-collection.js' + +export type ContextCountResult = CountResult & { + itemsPerContext: Record +} + +export type ContextCountResultWithLocations = CountResultWithLocations & { + itemsPerContext: Record +} + +export class ContextCountCollection { + private global: CountCollection + private contexts = new Map() + private withLocations: boolean + + constructor(withLocations: boolean) { + this.withLocations = withLocations + this.global = new CountCollection(withLocations) + } + + add( + value: string, + context: string, + line: number, + column: number, + offset: number, + length: number, + ): void { + this.global.add(value, line, column, offset, length) + + let ctx = this.contexts.get(context) + if (!ctx) { + ctx = new CountCollection(this.withLocations) + this.contexts.set(context, ctx) + } + ctx.add(value, line, column, offset, length) + } + + collect(): ContextCountResult | ContextCountResultWithLocations { + const globalResult = this.global.collect() + const itemsPerContext: Record = {} + for (const [ctx, coll] of this.contexts) { + itemsPerContext[ctx] = coll.collect() + } + return { ...globalResult, itemsPerContext } as ContextCountResult | ContextCountResultWithLocations + } +} diff --git a/src/v2/internals/count-collection.ts b/src/v2/internals/count-collection.ts new file mode 100644 index 0000000..c07c9d6 --- /dev/null +++ b/src/v2/internals/count-collection.ts @@ -0,0 +1,72 @@ +// Counts unique string values, optionally with locations. +// +// Internals: +// - StringInterner assigns dense integer IDs to strings. +// - GrowableUint32Array stores per-ID counts, indexed by ID. +// - Optional Map stores ordered locations per unique value. +// +// This avoids the heap-object-per-occurrence cost of Map +// with per-occurrence {line, col, ...} objects. + +import { StringInterner } from './string-interner.js' +import { GrowableUint32Array } from './growable-u32.js' +import { LocationStore, type Location } from './location-store.js' + +export type CountResult = { + total: number + totalUnique: number + uniquenessRatio: number + unique: Record +} + +export type CountResultWithLocations = CountResult & { + uniqueWithLocations: Record +} + +export class CountCollection { + private interner = new StringInterner() + private counts = new GrowableUint32Array(32) + private locs: Map | null + private totalCount = 0 + + constructor(withLocations: boolean) { + this.locs = withLocations ? new Map() : null + } + + add(value: string, line: number, column: number, offset: number, length: number): void { + const id = this.interner.intern(value) + this.counts.increment(id) + this.totalCount++ + + if (this.locs !== null) { + let store = this.locs.get(id) + if (!store) { + store = new LocationStore() + this.locs.set(id, store) + } + store.push(line, column, offset, length) + } + } + + collect(): CountResult | CountResultWithLocations { + const unique: Record = {} + for (const [str, id] of this.interner.entries()) { + unique[str] = this.counts.get(id) + } + + const base: CountResult = { + total: this.totalCount, + totalUnique: this.interner.size, + uniquenessRatio: this.totalCount === 0 ? 0 : this.interner.size / this.totalCount, + unique, + } + + if (this.locs === null) return base + + const uniqueWithLocations: Record = {} + for (const [id, store] of this.locs) { + uniqueWithLocations[this.interner.get(id)] = store.toArray() + } + return { ...base, uniqueWithLocations } + } +} diff --git a/src/v2/internals/growable-u32.ts b/src/v2/internals/growable-u32.ts new file mode 100644 index 0000000..59233d2 --- /dev/null +++ b/src/v2/internals/growable-u32.ts @@ -0,0 +1,46 @@ +// Uint32Array that doubles in capacity on demand. + +export class GrowableUint32Array { + private buf: Uint32Array + private len: number + + constructor(initialCapacity = 64) { + this.buf = new Uint32Array(initialCapacity) + this.len = 0 + } + + push(value: number): number { + const i = this.len + if (i >= this.buf.length) this.growTo(i + 1) + this.buf[i] = value + this.len = i + 1 + return i + } + + increment(i: number): void { + if (i >= this.buf.length) this.growTo(i + 1) + this.buf[i]++ + if (i >= this.len) this.len = i + 1 + } + + get(i: number): number { + return this.buf[i] ?? 0 + } + + get length(): number { + return this.len + } + + // Returns a view into the live buffer up to `len`. Do not retain after further growth. + view(): Uint32Array { + return this.buf.subarray(0, this.len) + } + + private growTo(minLen: number): void { + let cap = this.buf.length + while (cap < minLen) cap *= 2 + const next = new Uint32Array(cap) + next.set(this.buf) + this.buf = next + } +} diff --git a/src/v2/internals/location-store.ts b/src/v2/internals/location-store.ts new file mode 100644 index 0000000..d0006d6 --- /dev/null +++ b/src/v2/internals/location-store.ts @@ -0,0 +1,53 @@ +// Flat Uint32Array, 4 slots per location: [line, col, offset, length]. +// One LocationStore per unique value in a CountCollection; or a single store +// for ordered samples in a NumericCollection. + +export type Location = { + line: number + column: number + offset: number + length: number +} + +export class LocationStore { + private data: Uint32Array + private n: number + + constructor(initialCapacity = 4) { + this.data = new Uint32Array(initialCapacity * 4) + this.n = 0 + } + + push(line: number, column: number, offset: number, length: number): void { + const need = this.n + 4 + if (need > this.data.length) { + let cap = this.data.length + while (cap < need) cap *= 2 + const next = new Uint32Array(cap) + next.set(this.data) + this.data = next + } + this.data[this.n] = line + this.data[this.n + 1] = column + this.data[this.n + 2] = offset + this.data[this.n + 3] = length + this.n += 4 + } + + get count(): number { + return this.n >>> 2 + } + + toArray(): Location[] { + const out: Location[] = new Array(this.count) + for (let i = 0, j = 0; i < this.n; i += 4, j++) { + out[j] = { + line: this.data[i]!, + column: this.data[i + 1]!, + offset: this.data[i + 2]!, + length: this.data[i + 3]!, + } + } + return out + } +} diff --git a/src/v2/internals/numeric-collection.ts b/src/v2/internals/numeric-collection.ts new file mode 100644 index 0000000..c75a8e0 --- /dev/null +++ b/src/v2/internals/numeric-collection.ts @@ -0,0 +1,57 @@ +// Records a distribution of unsigned-integer samples (e.g. declarations-per-rule). +// +// Samples are stored in a GrowableUint32Array; locations (the originating node +// per sample) are stored in a parallel LocationStore. Aggregate statistics are +// not computed by this collection — callers should opt into them on top. + +import { GrowableUint32Array } from './growable-u32.js' +import { LocationStore, type Location } from './location-store.js' + +export type NumericResult = { + total: number + sum: number + items: number[] +} + +export type NumericResultWithLocations = NumericResult & { + itemsWithLocations: Array<{ value: number; location: Location }> +} + +export class NumericCollection { + private values = new GrowableUint32Array(64) + private locs: LocationStore | null + private sumValue = 0 + + constructor(withLocations: boolean) { + this.locs = withLocations ? new LocationStore() : null + } + + push(value: number, line: number, column: number, offset: number, length: number): void { + this.values.push(value) + this.sumValue += value + if (this.locs !== null) { + this.locs.push(line, column, offset, length) + } + } + + collect(): NumericResult | NumericResultWithLocations { + const view = this.values.view() + const items: number[] = new Array(view.length) + for (let i = 0; i < view.length; i++) items[i] = view[i]! + + const base: NumericResult = { + total: view.length, + sum: this.sumValue, + items, + } + + if (this.locs === null) return base + + const locArray = this.locs.toArray() + const itemsWithLocations = new Array(view.length) as NumericResultWithLocations['itemsWithLocations'] + for (let i = 0; i < view.length; i++) { + itemsWithLocations[i] = { value: items[i]!, location: locArray[i]! } + } + return { ...base, itemsWithLocations } + } +} diff --git a/src/v2/internals/string-interner.ts b/src/v2/internals/string-interner.ts new file mode 100644 index 0000000..5628102 --- /dev/null +++ b/src/v2/internals/string-interner.ts @@ -0,0 +1,29 @@ +// Maps strings to dense integer IDs. One per analyzer instance, so IDs are +// contiguous from 0 and can index directly into a parallel Uint32Array. + +export class StringInterner { + private map = new Map() + private strings: string[] = [] + + intern(s: string): number { + let id = this.map.get(s) + if (id === undefined) { + id = this.strings.length + this.strings.push(s) + this.map.set(s, id) + } + return id + } + + get(id: number): string { + return this.strings[id]! + } + + get size(): number { + return this.strings.length + } + + entries(): IterableIterator<[string, number]> { + return this.map.entries() + } +} diff --git a/src/v2/v2.test.ts b/src/v2/v2.test.ts new file mode 100644 index 0000000..0fed476 --- /dev/null +++ b/src/v2/v2.test.ts @@ -0,0 +1,688 @@ +import { test, expect, describe } from 'vitest' +import { + createPipeline, + uniqueColors, + declarationsPerRule, + linesOfCode, + uniqueMediaFeatures, + embeddedContent, + stylesheetMeta, + sourceLinesOfCode, + atruleImports, + atruleCharsets, + atruleLayers, + atruleFontFaces, + atruleKeyframes, + atruleMedia, + atruleSupports, + atruleContainers, + atruleMisc, + rules, + selectors, + declarations, + properties, + gradients, + fontFamilies, + fontSizes, + lineHeights, + zIndexes, + shadows, + borderRadii, + animations, + units, + keywords, + resets, + displays, + colorFormats, + vendorPrefixedValues, + valueBrowserhacks, +} from './index.js' +import type { + CountResultWithLocations, + NumericResultWithLocations, + EmbeddedContentResultWithLocations, +} from './index.js' + +const CSS = ` +:root { --brand: #f0f; } + +.hero { + color: red; + background: rgb(0 0 0 / 0.5); + border: 1px solid #000; +} + +.card { + color: red; + background: linear-gradient(to right, #fff, blue); + font-family: Black, sans-serif; +} + +@media (min-width: 600px) { + .hero { + color: rebeccapurple; + } +} +` + +describe('v2 pipeline — without locations', () => { + const result = createPipeline({ + colors: uniqueColors(), + decls: declarationsPerRule(), + }).run(CSS) + + test('colors: counts every color occurrence across the whole stylesheet', () => { + // 8 occurrences: #f0f, red, rgb(...), #000, red, #fff, blue, rebeccapurple + expect(result.colors.total).toBe(8) + // 7 unique (red appears twice) + expect(result.colors.totalUnique).toBe(7) + }) + + test('colors: red is counted twice (.hero and .card)', () => { + expect(result.colors.unique['red']).toBe(2) + }) + + test('colors: "Black" inside font-family is NOT counted as a color', () => { + expect(result.colors.unique['black']).toBeUndefined() + }) + + test('colors: blue inside linear-gradient IS counted', () => { + expect(result.colors.unique['blue']).toBe(1) + }) + + test('colors: hex values are lowercased', () => { + expect(result.colors.unique['#fff']).toBe(1) + expect(result.colors.unique['#000']).toBe(1) + expect(result.colors.unique['#f0f']).toBe(1) + }) + + test('decls: one entry per style rule', () => { + // :root (1), .hero (3), .card (3), .hero inside @media (1) = 4 rules + expect(result.decls.total).toBe(4) + expect(result.decls.sum).toBe(8) + expect(result.decls.items).toEqual([1, 3, 3, 1]) + }) + + test('decls: no locations in result by default', () => { + expect('itemsWithLocations' in result.decls).toBe(false) + }) + + test('colors: no locations in result by default', () => { + expect('uniqueWithLocations' in result.colors).toBe(false) + }) +}) + +describe('v2 pipeline — with locations', () => { + const result = createPipeline({ + colors: uniqueColors({ locations: true }), + decls: declarationsPerRule({ locations: true }), + }).run(CSS) + + test('colors: each unique color has one Location per occurrence', () => { + const colors = result.colors as CountResultWithLocations + expect(colors.uniqueWithLocations['red']).toHaveLength(2) + expect(colors.uniqueWithLocations['blue']).toHaveLength(1) + expect(colors.uniqueWithLocations['#000']).toHaveLength(1) + }) + + test('colors: location objects have line/column/offset/length', () => { + const colors = result.colors as CountResultWithLocations + const loc = colors.uniqueWithLocations['red']![0]! + expect(loc.line).toBeGreaterThan(0) + expect(loc.column).toBeGreaterThan(0) + expect(loc.offset).toBeGreaterThanOrEqual(0) + expect(loc.length).toBeGreaterThan(0) + // Verify the offset+length actually point at "red" + expect(CSS.slice(loc.offset, loc.offset + loc.length)).toBe('red') + }) + + test('decls: items array order matches itemsWithLocations order', () => { + const decls = result.decls as NumericResultWithLocations + expect(decls.itemsWithLocations.map((x) => x.value)).toEqual(decls.items) + }) + + test('decls: each item points at the originating rule', () => { + const decls = result.decls as NumericResultWithLocations + const first = decls.itemsWithLocations[0]! + // First rule is :root with 1 declaration + expect(first.value).toBe(1) + expect(CSS.slice(first.location.offset, first.location.offset + first.location.length)).toMatch( + /^:root\s*\{[^}]*\}/, + ) + }) +}) + +// ─── linesOfCode ──────────────────────────────────────────────────────────── + +describe('linesOfCode', () => { + test('counts newline-terminated lines', () => { + const r = createPipeline({ loc: linesOfCode() }).run('a {\n color: red;\n}\n') + expect(r.loc.total).toBe(4) + }) + + test('counts a single line with no newline', () => { + const r = createPipeline({ loc: linesOfCode() }).run('a { color: red }') + expect(r.loc.total).toBe(1) + }) + + test('empty string is 1 line', () => { + const r = createPipeline({ loc: linesOfCode() }).run('') + expect(r.loc.total).toBe(1) + }) + + test('counts correctly on the shared CSS fixture', () => { + const r = createPipeline({ loc: linesOfCode() }).run(CSS) + // CSS fixture has 22 lines (count \n occurrences + 1) + expect(r.loc.total).toBe(CSS.split('\n').length) + }) +}) + +// ─── uniqueMediaFeatures ───────────────────────────────────────────────────── + +const MEDIA_CSS = ` +@media (min-width: 600px) { .a { color: red } } +@media (max-width: 1200px) and (min-width: 400px) { .b { color: blue } } +@media (hover: hover) { .c { color: green } } +@media (min-width: 900px) { .d { color: pink } } +` + +describe('uniqueMediaFeatures — without locations', () => { + const r = createPipeline({ mf: uniqueMediaFeatures() }).run(MEDIA_CSS) + + test('counts total feature occurrences', () => { + // min-width (×3), max-width (×1), hover (×1) + expect(r.mf.total).toBe(5) + }) + + test('counts unique feature names', () => { + expect(r.mf.totalUnique).toBe(3) + }) + + test('min-width appears 3 times', () => { + expect(r.mf.unique['min-width']).toBe(3) + }) + + test('feature names are lowercased', () => { + expect(r.mf.unique['hover']).toBe(1) + expect(r.mf.unique['max-width']).toBe(1) + }) +}) + +describe('uniqueMediaFeatures — with locations', () => { + const r = createPipeline({ mf: uniqueMediaFeatures({ locations: true }) }).run(MEDIA_CSS) + + test('min-width has 3 location entries', () => { + const mf = r.mf as CountResultWithLocations + expect(mf.uniqueWithLocations['min-width']).toHaveLength(3) + }) + + test('locations have valid line/column/offset/length', () => { + const mf = r.mf as CountResultWithLocations + const loc = mf.uniqueWithLocations['hover']![0]! + expect(loc.line).toBeGreaterThan(0) + expect(loc.offset).toBeGreaterThanOrEqual(0) + expect(loc.length).toBeGreaterThan(0) + }) +}) + +// ─── embeddedContent ───────────────────────────────────────────────────────── + +const GIF_DATA = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' +const SVG_DATA = 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22/%3E' + +const EMBED_CSS = ` +.a { background: url("${GIF_DATA}") } +.b { background: url('${SVG_DATA}') } +.c { background: url("${GIF_DATA}") } +.d { background: url(https://example.com/img.png) } +` + +describe('embeddedContent — without locations', () => { + const r = createPipeline({ ec: embeddedContent() }).run(EMBED_CSS) + + test('counts only data URIs, not regular URLs', () => { + expect(r.ec.totalCount).toBe(3) + }) + + test('accumulates total byte size of all data URIs', () => { + expect(r.ec.totalSize).toBe(GIF_DATA.length * 2 + SVG_DATA.length) + }) + + test('sizeRatio is totalSize / css.length', () => { + expect(r.ec.sizeRatio).toBeCloseTo(r.ec.totalSize / EMBED_CSS.length) + }) + + test('groups by MIME type', () => { + expect(r.ec.unique['image/gif']!.count).toBe(2) + expect(r.ec.unique['image/svg+xml']!.count).toBe(1) + }) + + test('per-type size is cumulative', () => { + expect(r.ec.unique['image/gif']!.size).toBe(GIF_DATA.length * 2) + }) + + test('no locations field without option', () => { + expect('locations' in (r.ec.unique['image/gif'] ?? {})).toBe(false) + }) +}) + +describe('embeddedContent — with locations', () => { + const r = createPipeline({ ec: embeddedContent({ locations: true }) }).run(EMBED_CSS) + + test('each MIME type has one location per occurrence', () => { + const ec = r.ec as EmbeddedContentResultWithLocations + expect(ec.unique['image/gif']!.locations).toHaveLength(2) + expect(ec.unique['image/svg+xml']!.locations).toHaveLength(1) + }) + + test('location offset points at the url() token', () => { + const ec = r.ec as EmbeddedContentResultWithLocations + const loc = ec.unique['image/svg+xml']!.locations[0]! + expect(EMBED_CSS.slice(loc.offset, loc.offset + loc.length)).toMatch(/^url\(/) + }) +}) + +// ─── stylesheetMeta ───────────────────────────────────────────────────────── + +describe('stylesheetMeta', () => { + test('reports byte size', () => { + const css = 'a { color: red }' + const r = createPipeline({ m: stylesheetMeta() }).run(css) + expect(r.m.size).toBe(css.length) + }) + + test('counts comments and their byte size', () => { + const r = createPipeline({ m: stylesheetMeta() }).run('/* hello */ a { color: red } /* world */') + expect(r.m.comments.total).toBe(2) + expect(r.m.comments.size).toBe('/* hello */'.length + ' /* world */'.length - 1) // both comments + }) + + test('zero comments on comment-free css', () => { + const r = createPipeline({ m: stylesheetMeta() }).run('a { color: red }') + expect(r.m.comments.total).toBe(0) + expect(r.m.comments.size).toBe(0) + }) +}) + +// ─── sourceLinesOfCode ─────────────────────────────────────────────────────── + +describe('sourceLinesOfCode', () => { + test('counts atrules + selectors + declarations', () => { + const css = '@media (min-width: 600px) { .a { color: red; font-size: 12px } }' + const r = createPipeline({ sloc: sourceLinesOfCode() }).run(css) + // 1 @media + 1 selector + 2 declarations = 4 + expect(r.sloc.total).toBe(4) + }) +}) + +// ─── at-rule analyzers ─────────────────────────────────────────────────────── + +describe('atruleImports', () => { + test('counts @import rules', () => { + const r = createPipeline({ a: atruleImports() }).run('@import "a.css"; @import "b.css";') + expect(r.a.total).toBe(2) + expect(r.a.unique['"a.css"']).toBe(1) + }) +}) + +describe('atruleCharsets', () => { + test('counts @charset', () => { + const r = createPipeline({ a: atruleCharsets() }).run('@charset "utf-8";') + expect(r.a.total).toBe(1) + expect(r.a.unique['"utf-8"']).toBe(1) + }) +}) + +describe('atruleLayers', () => { + test('counts named layers', () => { + const r = createPipeline({ a: atruleLayers() }).run('@layer base, theme; @layer utilities {}') + expect(r.a.total).toBe(3) + }) + + test('anonymous layers get key', () => { + const r = createPipeline({ a: atruleLayers() }).run('@layer {}') + expect(r.a.unique['']).toBe(1) + }) +}) + +describe('atruleFontFaces', () => { + const css = ` + @font-face { font-family: "MyFont"; src: url("myfont.woff2") } + @font-face { font-family: "OtherFont"; src: url("other.woff2") } + ` + test('counts each @font-face block', () => { + const r = createPipeline({ a: atruleFontFaces() }).run(css) + expect(r.a.total).toBe(2) + }) + test('extracts descriptors', () => { + const r = createPipeline({ a: atruleFontFaces() }).run(css) + expect(r.a.unique[0]!['font-family']).toBe('"MyFont"') + }) +}) + +describe('atruleKeyframes', () => { + test('counts @keyframes by name', () => { + const r = createPipeline({ a: atruleKeyframes() }).run('@keyframes spin {} @keyframes fade {}') + expect(r.a.total).toBe(2) + expect(r.a.unique['spin']).toBe(1) + }) + + test('prefixed @keyframes tracked separately', () => { + const r = createPipeline({ a: atruleKeyframes() }).run('@keyframes foo {} @-webkit-keyframes foo {}') + expect(r.a.prefixed.total).toBe(1) + expect(r.a.prefixedRatio).toBe(0.5) + }) +}) + +describe('atruleMedia', () => { + test('counts @media queries', () => { + const r = createPipeline({ a: atruleMedia() }).run('@media (min-width: 600px) {} @media print {}') + expect(r.a.queries.total).toBe(2) + }) +}) + +describe('atruleSupports', () => { + test('counts @supports queries', () => { + const r = createPipeline({ a: atruleSupports() }).run('@supports (display: grid) {}') + expect(r.a.queries.total).toBe(1) + }) +}) + +describe('atruleContainers', () => { + test('counts @container queries', () => { + const r = createPipeline({ a: atruleContainers() }).run('@container sidebar (min-width: 300px) {}') + expect(r.a.queries.total).toBe(1) + expect(r.a.names.unique['sidebar']).toBe(1) + }) +}) + +describe('atruleMisc', () => { + test('tracks nesting depth of atrules', () => { + const r = createPipeline({ a: atruleMisc() }).run('@media screen { @supports (display: grid) {} }') + expect(r.a.nesting.total).toBe(2) + expect(r.a.nesting.min).toBe(0) + expect(r.a.nesting.max).toBe(1) + }) + + test('counts @property', () => { + const r = createPipeline({ a: atruleMisc() }).run('@property --my-color { syntax: "" }') + expect(r.a.registeredProperties.total).toBe(1) + }) +}) + +// ─── rules ─────────────────────────────────────────────────────────────────── + +const RULES_CSS = ` +.a { color: red; font-size: 12px } +.b.c { margin: 0 } +.empty {} +@media screen { .d { padding: 4px } } +` + +describe('rules', () => { + const r = createPipeline({ r: rules() }).run(RULES_CSS) + + test('counts total non-keyframe rules', () => { + expect(r.r.total).toBe(4) + }) + + test('detects empty rules', () => { + expect(r.r.empty.total).toBe(1) + expect(r.r.empty.ratio).toBeCloseTo(1 / 4) + }) + + test('tracks selectors per rule distribution', () => { + // .a → 1, .b.c → 1, .empty → 1, .d → 1 + expect(r.r.selectorsPerRule.max).toBe(1) + expect(r.r.selectorsPerRule.sum).toBe(4) + }) + + test('nesting: @media child rule has depth > 0', () => { + expect(r.r.nesting.max).toBeGreaterThan(0) + }) +}) + +// ─── selectors ─────────────────────────────────────────────────────────────── + +const SELECTORS_CSS = ` +#id .class { color: red } +a:hover { color: blue } +.a, .b { color: green } +[data-active] { color: pink } +my-element { color: orange } +` + +describe('selectors', () => { + const r = createPipeline({ s: selectors() }).run(SELECTORS_CSS) + + test('counts total selectors', () => { + // #id .class, a:hover, .a, .b, [data-active], my-element = 6 + expect(r.s.total).toBe(6) + }) + + test('specificity stats are computed', () => { + expect(r.s.specificity.max).toHaveLength(3) + expect(r.s.specificity.min).toHaveLength(3) + // #id → [1,1,0], so max.a should be 1 + expect(r.s.specificity.max[0]).toBe(1) + }) + + test('tracks id selectors', () => { + expect(r.s.id.total).toBe(1) + }) + + test('tracks pseudo-classes', () => { + expect(r.s.pseudoClasses.unique['hover']).toBe(1) + }) + + test('tracks attribute selectors', () => { + expect(r.s.attributes.unique['data-active']).toBe(1) + }) + + test('tracks custom elements', () => { + expect(r.s.customElements.unique['my-element']).toBe(1) + }) + + test('keyframe selectors excluded from regular count', () => { + const css = '@keyframes spin { from {} to {} } .a { color: red }' + const r2 = createPipeline({ s: selectors() }).run(css) + expect(r2.s.total).toBe(1) + expect(r2.s.keyframes.total).toBe(2) + }) +}) + +// ─── declarations ──────────────────────────────────────────────────────────── + +describe('declarations', () => { + const css = '.a { color: red !important; font-size: 12px } @keyframes x { from { opacity: 1 !important } }' + const r = createPipeline({ d: declarations() }).run(css) + + test('counts all declarations', () => { + expect(r.d.total).toBe(3) + }) + + test('counts !important declarations', () => { + expect(r.d.importants.total).toBe(2) + }) + + test('tracks !important inside @keyframes separately', () => { + expect(r.d.importants.inKeyframes.total).toBe(1) + }) +}) + +// ─── properties ────────────────────────────────────────────────────────────── + +describe('properties', () => { + const css = '.a { color: red; -webkit-transform: rotate(45deg); --my-var: 1; margin: 0 }' + const r = createPipeline({ p: properties() }).run(css) + + test('counts total properties', () => { + expect(r.p.total).toBe(4) + }) + + test('counts vendor-prefixed properties', () => { + expect(r.p.prefixed.total).toBe(1) + }) + + test('counts custom properties', () => { + expect(r.p.custom.total).toBe(1) + }) + + test('counts shorthand properties', () => { + // margin is a shorthand + expect(r.p.shorthands.total).toBe(1) + }) +}) + +// ─── value analyzers ───────────────────────────────────────────────────────── + +describe('gradients', () => { + test('counts gradient functions', () => { + const r = createPipeline({ g: gradients() }).run('.a { background: linear-gradient(red, blue) }') + expect(r.g.total).toBe(1) + }) +}) + +describe('fontFamilies', () => { + test('extracts font-family values', () => { + const r = createPipeline({ f: fontFamilies() }).run('.a { font-family: Arial, sans-serif }') + expect(r.f.total).toBe(1) + }) + + test('extracts from font shorthand', () => { + const r = createPipeline({ f: fontFamilies() }).run('.a { font: 12px "Helvetica" }') + expect(r.f.total).toBe(1) + }) +}) + +describe('fontSizes', () => { + test('extracts font-size values', () => { + const r = createPipeline({ f: fontSizes() }).run('.a { font-size: 16px } .b { font-size: 1rem }') + expect(r.f.totalUnique).toBe(2) + }) +}) + +describe('lineHeights', () => { + test('extracts line-height values', () => { + const r = createPipeline({ f: lineHeights() }).run('.a { line-height: 1.5 } .b { line-height: 24px }') + expect(r.f.totalUnique).toBe(2) + }) +}) + +describe('zIndexes', () => { + test('extracts z-index values', () => { + const r = createPipeline({ z: zIndexes() }).run('.a { z-index: 10 } .b { z-index: 100 } .c { z-index: 10 }') + expect(r.z.total).toBe(3) + expect(r.z.totalUnique).toBe(2) + }) +}) + +describe('shadows', () => { + test('tracks text-shadow and box-shadow separately', () => { + const r = createPipeline({ s: shadows() }).run( + '.a { text-shadow: 1px 1px black; box-shadow: 0 2px 4px red }', + ) + expect(r.s.textShadows.total).toBe(1) + expect(r.s.boxShadows.total).toBe(1) + }) +}) + +describe('borderRadii', () => { + test('extracts border-radius values with property context', () => { + const r = createPipeline({ b: borderRadii() }).run( + '.a { border-radius: 4px; border-top-left-radius: 2px }', + ) + expect(r.b.total).toBe(2) + expect(Object.keys(r.b.itemsPerContext)).toContain('border-radius') + }) +}) + +describe('animations', () => { + test('extracts durations from animation shorthand', () => { + const r = createPipeline({ a: animations() }).run('.a { animation: spin 1s ease-in-out }') + expect(r.a.durations.total).toBe(1) + expect(r.a.durations.unique['1s']).toBe(1) + }) + + test('extracts timing functions', () => { + const r = createPipeline({ a: animations() }).run('.a { animation: spin 1s ease-in-out }') + expect(r.a.timingFunctions.unique['ease-in-out']).toBe(1) + }) +}) + +describe('units', () => { + test('extracts units with property context', () => { + const r = createPipeline({ u: units() }).run('.a { font-size: 16px; margin: 1rem; width: 100% }') + expect(r.u.unique['px']).toBe(1) + expect(r.u.unique['rem']).toBe(1) + expect(Object.keys(r.u.itemsPerContext)).toContain('font-size') + }) +}) + +describe('keywords', () => { + test('tracks CSS keywords like auto/none/inherit', () => { + const r = createPipeline({ k: keywords() }).run('.a { display: none } .b { overflow: auto }') + expect(r.k.unique['none']).toBe(1) + expect(r.k.unique['auto']).toBe(1) + }) +}) + +describe('resets', () => { + test('tracks spacing properties set to zero', () => { + const r = createPipeline({ rs: resets() }).run('.a { margin: 0; padding: 0 }') + expect(r.rs.total).toBe(2) + expect(r.rs.unique['margin']).toBe(1) + }) +}) + +describe('displays', () => { + test('tracks display property values', () => { + const r = createPipeline({ d: displays() }).run('.a { display: flex } .b { display: grid } .c { display: flex }') + expect(r.d.total).toBe(3) + expect(r.d.unique['flex']).toBe(2) + }) +}) + +describe('colorFormats', () => { + test('distinguishes hex vs named vs function colors', () => { + const r = createPipeline({ cf: colorFormats() }).run( + '.a { color: #f00; background: red; border-color: rgb(0 0 255) }', + ) + expect(r.cf.unique['hex3']).toBe(1) + expect(r.cf.unique['named']).toBe(1) + expect(r.cf.unique['rgb']).toBe(1) + }) +}) + +describe('vendorPrefixedValues', () => { + test('detects vendor-prefixed values', () => { + const r = createPipeline({ vp: vendorPrefixedValues() }).run( + '.a { display: -webkit-flex }', + ) + expect(r.vp.total).toBeGreaterThan(0) + }) +}) + +describe('valueBrowserhacks', () => { + test('detects progid: IE filter hack', () => { + const css = + '.a { filter: progid:DXImageTransform.Microsoft.gradient(startColorstr="#f00", endColorstr="#00f") }' + const r = createPipeline({ bh: valueBrowserhacks() }).run(css) + expect(r.bh.total).toBeGreaterThan(0) + expect(r.bh.unique['progid:']).toBeGreaterThan(0) + }) +}) + +describe('v2 pipeline — tree-shaking shape', () => { + test('can run with just colors, no declarations analyzer', () => { + const result = createPipeline({ colors: uniqueColors() }).run('a { color: red }') + expect(result.colors.total).toBe(1) + // @ts-expect-error — decls was not requested + expect(result.decls).toBeUndefined() + }) + + test('can run with just declarationsPerRule, no colors analyzer', () => { + const result = createPipeline({ d: declarationsPerRule() }).run('a { color: red; font-size: 12px }') + expect(result.d.items).toEqual([2]) + }) +})