
Security News
New Study Identifies 53 Slopsquatting Targets Across 5 Frontier LLMs
Five frontier LLMs generated the same nonexistent package names, leaving 53 available for potential slopsquatting across PyPI and npm.
CSS Dedup is a CSS maintainability and performance optimization tool that finds—and, when requested and where safe, consolidates—duplicate CSS declarations. It implements the technique of using declarations just once (“UDJO”) as described in 2008 and detailed in 2017: the same normalized property–value pair shouldn’t appear in more than one rule within the same scope. Where it does, CSS Dedup reports it—and allows to optimize the respective style sheet.
Note: CSS Dedup is still brand-new 🆕 and needs to be battle-tested 🧪. Please report any issues.
Given:
.a {
color: red;
font-weight: bold;
}
.b {
color: red;
}
$ npx css-dedup default.css
(root)
duplicate color: red
.a (line 2)
.b (line 7)
Summary: 1 finding
Run with `--fix` to save 10 bytes (15.9%).
Running with --fix folds .a and .b into a single rule for the shared declaration:
.a {
font-weight: bold;
}
.a, .b {
color: red;
}
$ npx css-dedup --fix default.css
1 consolidated, 0 skipped
63 → 53 bytes (-10 B, -15.9%)
Wrote default.css
Since duplicate declarations cost bytes wherever they live—in the style sheet itself, and (uncompressed) over the wire—the byte counts reflect two payoffs at once: less to maintain, and less to transfer.
The two aren’t always aligned, though: Folding a declaration into a shared selector list adds that list’s bytes back, so consolidating one that only has a couple of long, otherwise-unrelated selectors in common can end up costing more than it removes. CSS Dedup’s two modes call this out, so it’s a decision you can make consciously—it’s worth it if you value using declarations once for maintainability, yet not if you’re optimizing purely for transfer size. (--fix --savings-only automates that call: A file whose consolidation would grow it is left untouched.)
npx css-dedup [options] <file…>
Pass one or more files—each is analyzed (and, with --fix, rewritten) independently. A directory is searched recursively for .css files (skipping node_modules and dotfolders); the result is unrolled into that same per-file list, so mixing files and directories works, too. Pass - instead of a file to read CSS from STDIN (can’t be combined with other file arguments); in --fix mode this prints the consolidated CSS to STDOUT, rather than writing a file, so it composes in a pipeline (status/summary output moves to STDERR in that case, keeping STDOUT pure CSS).
| Option | Description |
|---|---|
--fix, -f | Consolidate declarations that are safe to merge automatically, rewriting each file in place (or printing to STDOUT for -) |
--aggressive, -a | Also allow merges that are probably—but not provably—safe (see aggressive mode); on its own this widens the report, with --fix it applies the merges |
--savings-only, -s | Leave a file untouched when its consolidation would make it bigger, not smaller (checked per file); only valid together with --fix, since report mode doesn’t write |
--ignore-selector <pattern>, -i | Regular expression for selectors to exclude from analysis (repeatable) |
--no-ignore-selectors-defaults, -n | Disable the built-in selector hack ignore list |
--ignore-path <pattern>, -p | Regular expression tested against each file’s path, relative to the working directory; a match excludes the file (repeatable) |
--config <path>, -c <path> | Path to a config file (defaults to css-dedup.config.js in the working directory, if present) |
--help, -h | Show usage information |
--ignore-selector and --ignore-path are singular because they’re repeatable flags—each occurrence (-i pattern1 -i pattern2) adds one pattern. The corresponding config-file options, ignoreSelectors and ignorePaths, take an array. --ignore-path excludes whole files by path rather than by selector content, matched against each file’s path relative to the working directory—useful for keeping a directory scan out of a build output folder (node_modules and dotfolders are always skipped; nothing else is otherwise).
Without --fix, CSS Dedup only reports. Report mode still runs the same safety checks --fix would, though, so a finding that is considered unsafe to auto-merge (an intervening declaration on some other selector, say) is called out right there, alongside the byte estimate for whatever is safe—rather than the estimate silently going missing for that group. Exit code is 1 if it finds anything to report (or, with --fix, anything skipped as unsafe or withheld by --savings-only) in any of the given files.
A file that fails to parse—invalid CSS, or a non-standard dialect PostCSS doesn’t accept—doesn’t stop the run: Its error is reported and CSS Dedup moves on to the rest.
Note: --fix rewrites CSS text only—it doesn’t regenerate a source map. If a rewritten file references one (a /*# sourceMappingURL=… */ comment, typically left by a build tool), CSS Dedup notes that the map is now stale.
For settings that should apply on every run—typically a project’s own ignoreSelectors—drop a css-dedup.config.js in the working directory (or point --config at one elsewhere, under any name).
These are the supported options with their defaults (each can be omitted):
// css-dedup.config.js
export default {
aggressive: false, // set to `true` to also allow probably-safe merges
savingsOnly: false, // set to `true` to skip files whose consolidation would grow them (`--fix` runs only)
ignoreSelectors: [], // additional selector patterns to exclude, e.g., `[/^\.legacy-/]`
ignoreSelectorsDefaults: true, // set to `false` to disable the built-in hack list
ignorePaths: [] // file paths to exclude, matched relative to the working directory, e.g., `[/dist\//]`
};
CLI flags layer on top of the config file rather than replacing it: --ignore-selector patterns are added to ignoreSelectors from the config, --ignore-path patterns are added to ignorePaths, and --no-ignore-selectors-defaults always wins over ignoreSelectorsDefaults: true in the config. savingsOnly is a consolidation policy, so on plain report runs it only adjusts the payoff line (a report doesn’t write anyway); on --fix runs it decides whether the file is written.
Install CSS Dedup in your project, e.g., via npm i -D css-dedup, then import and use what you need:
import { analyze, dedup } from 'css-dedup';
const { findings } = analyze(css);
const { css: output, applied, skipped } = dedup(css);
analyze() is report mode; dedup() is fix mode.
Both functions accept an options object:
{
from: 'path/to/file.css', // forwarded to PostCSS; names the file in syntax-error messages
aggressive: false, // set to `true` to also allow probably-safe merges
savingsOnly: false, // set to `true` to withhold a consolidation that would grow the style sheet (`dedup()` only)
ignoreSelectors: [/^\.legacy-/], // additional selector patterns to exclude
ignoreSelectorsDefaults: true // set to `false` to disable the built-in hack list
}
analyze() returns { findings }, an array of objects:
{
scope, // `root`, or the at-rule chain the rules live in, e.g., `@media (min-width: 768px)`
key, // normalized `prop: value` (plus ` !important` if set)
redundant, // “true” if the same declaration repeats within one rule, absent otherwise
repeated, // “true” if this flags a selector (list) written more than once in one scope;
// `key` is then the selector list, and occurrences carry no `prop`/`value`
occurrences, // [{ selector, selectors, prop, value, line }, …]
}
dedup() returns { css, applied, skipped, bytes }: css is the rewritten style sheet; applied lists what it did—each entry has redundant: true if it just dropped a same-rule (or same-at-rule-block) duplicate, folded: true if it folded a rule repeating the same selector into a later one, absent if it folded selectors from separate rules into one; skipped lists duplicate groups (and blocked same-selector folds) it left untouched along with why; and bytes is { before, after, saved }—UTF-8 byte counts of the style sheet before and after, since that’s what changes over the wire, not the character count, covering everything --fix did as one net figure.
saved is before - after, so it’s negative on the rare file where the added selector-list text outweighs the removed declarations—dropping a same-rule duplicate never costs bytes, only folding selectors from separate rules can. With savingsOnly: true, a consolidation whose net saved would be negative is withheld: css comes back untouched, applied is empty, bytes reports no change (that’s what actually happened), and the declined outcome arrives as withheld: { count, bytes }—the number of merges and the byte counts the consolidation would have had (withheld is absent whenever nothing was withheld). dedupRoot() (the same function, operating on an already-parsed PostCSS root instead of a CSS string) returns the same shape minus css.
For dropping CSS Dedup into an existing PostCSS pipeline (alongside Autoprefixer, cssnano, etc.) instead of running it as a separate file-based pass, import the plugin from css-dedup/plugin:
import postcss from 'postcss';
import cssdedup from 'css-dedup/plugin';
// Report mode: Duplicate/redundant declarations surface as PostCSS warnings
const result = await postcss([cssdedup()]).process(css, { from: 'default.css' });
console.log(result.warnings());
// Fix mode: Rewrites the root in place; skipped merges still surface as warnings
const fixed = await postcss([cssdedup({ fix: true })]).process(css, { from: 'default.css' });
console.log(fixed.css);
The plugin takes the same options as analyze()/dedup(), plus fix: true to switch it into consolidation mode (aggressive: true and savingsOnly: true work here, too—a withheld consolidation leaves the root untouched and surfaces as a warning). Since CSS Dedup is a source-hygiene tool—more like stylelint --fix than a bundle optimizer—it belongs early in a pipeline, on hand-authored CSS, before Autoprefixer and before minification; running it after either may overlap with work those tools do.
CSS Dedup:
…parses the CSS with PostCSS.
…scopes rules by their DRY boundary—the root style sheet, the contents of an @media/@supports/@layer condition, or one specific nested rule (native CSS nesting).
@layers (or different @media/@supports conditions) can’t share a merged rule.@media (min-width: 768px) {} blocks in different parts of the file)—matching is whitespace-insensitive but case-sensitive, since @layer names and selectors can be case-significant.--fix is more conservative here: It only ever folds rules that already live in the same physical block, since merging across two separate blocks would relocate a declaration past whatever sits between those blocks in the source—including rules in an entirely different scope, which the merge-safety check (step 6) has no visibility into. A duplicate split across two same-condition blocks is therefore reported, not auto-merged (unless --aggressive is enabled—see aggressive mode).@layer reset, base;) are skipped.…excludes selectors matching a hack pattern (vendor-prefixed pseudo-classes/elements, legacy IE selector hacks) from analysis by default—grouping those into a shared selector list risks the whole rule being dropped by browsers that don’t recognize the selector.
…normalizes each remaining declaration for comparison.
url(), and custom property names throughout—those are case-sensitive, so var(--Foo)/var(--foo) and --Foo/--foo are never treated as equal. Everything around such a protected segment still normalizes, though: VAR( --brand ) matches var(--brand), and var(--m, 0px) matches var(--m,0).--brand: #FFF and --brand: #fff don’t match)—they’re substituted as-is wherever var() references them—possibly somewhere case-sensitive—and scripts can read them back via getPropertyValue(), so no two spellings are provably interchangeable (even --x: 0px and --x: 0 differ—only one is a valid z-index: var(--x)).rgb( 255, 0, 0 ) matches rgb(255,0,0)), and folds value case—except for properties whose value is (or can contain) an author-defined custom ident (animation-name, counter-reset, container-name, and similar), since those are case-sensitive per CSS, unlike the predefined keywords everywhere else; animation-name: Foo and animation-name: foo can name two different @keyframes blocks, so folding them would risk a false duplicate.0px/0svh/0cqw → 0)—angle/time/frequency/resolution units like 0deg/0s are left alone, since unitless zero isn’t valid there. Zero percentages (0%) are collapsed to 0, too, except for a short list of properties where a percentage can resolve against an indefinite reference size..5/0.5/0.50 → .5, 1.0 → 1), drops a redundant leading + sign (+2px → 2px), and ignores whitespace around / separators (12px/1.5 matches 12px / 1.5).white, #fff, #ffffff, #ffffffff, rgb(255, 255, 255), and rgb(255 255 255) all compare equal, as do transparent and rgba(0, 0, 0, 0). Only lossless textual equivalences count—hsl() and percentage channels involve rounding, so they’re left alone (except in aggressive mode, which accepts the rounding).font-weight: bold/700 and normal/400 as equivalent (the longhand only—picking the weight out of the font shorthand would require parsing the value).margin: 0 0 matches margin: 0, padding: 1px 2px 1px 2px matches 1px 2px, border-radius: 1px/1px matches 1px, and two-value pairs like gap/overflow/place-items collapse the same way.border/outline none and 0 values as equivalent.<time> values to milliseconds: 0.3s matches 300ms. Always exact—converting s to ms is a decimal-point shift, never rounding—so this runs regardless of aggressive mode, the same as the zero-value and decimal collapsing above.min()/max() arguments, including nested calls: min(100%, 500px) matches min(500px, 100%), since mathematical min/max is commutative. clamp()’s three arguments are positional (minimum, preferred, maximum) and are left in place, as is minmax() (grid track sizing—a different function, despite the name).<angle> values to degrees—90deg matches 0.25turn and 100grad. grad/turn convert to degrees exactly; rad involves π, so that conversion is rounded, the same lossy-but-aggressive-only treatment hsl() gets above.…reports any normalized declaration that occurs in more than one rule within a scope, and separately flags declarations repeated within a single rule—including within a selector-less at-rule block like @font-face or @page, which have declarations of their own but, unlike two rules, are never compared against each other (there’s no selector list to fold two @font-face blocks into). It also reports a selector (list) written more than once within one scope—the same smell one level up from a repeated declaration—matched as a set, so .a, .b and .b, .a count as the same selector list; only within one physical block, though, since two same-condition @media blocks repeat their selectors by construction.
…consolidates (with --fix) only when it’s provably safe.
.5 over 0.50)—CSS Dedup only picks among spellings already present in the source, so it doesn’t synthesize a shorter one, which would be a minifier’s job.margin and margin-left, border-color and border-top-color, etc.), for any selector.html[lang="da"] a vs. html[lang="de"] a, since an attribute can only ever hold one value and html is unique per document)—it can’t actually match the same element, so it’s not a threat to this particular merge and doesn’t block it. (Aggressive mode widens this exception to selectors that are merely likely disjoint.) “Provably the same element” means the differing attribute sits on the selector’s subject, is connected to it purely through >/+ combinators, or sits on html/:root; across a descendant or ~ combinator, .x[data-v="1"] p and .x[data-v="2"] p can match the very same p (nested .x wrappers), so those don’t count as exclusive.Overall, CSS Dedup is conservative by design and will leave some safe merges for manual review.
test/fixtures/*.css contains small example style sheets that exercise each of these behaviors, including nesting (nesting.css) and @layer (layers.css)—run node bin/css-dedup.js test/fixtures/<file>.css (add --fix for merge-safety.css, and --aggressive for aggressive.css) to see them in action.
By default, CSS Dedup only consolidates what it can prove safe. --aggressive (-a) also allows merges that are probably safe:
Merging across separately-written same-condition blocks. Two @media (min-width: 768px) blocks apply under the exact same runtime condition, so their duplicates consolidate—usually the biggest single lever on real files. The accepted risk: Rules from other scopes sitting between the two blocks stay invisible to the intervening-rule safety check, so a merge could move a declaration past one that matters. A conditional block this empties (@media, @supports, @container) is removed; an emptied @layer shell is kept, since a layer’s first appearance sets the layer order.
Assuming rules with disjoint-looking selectors don’t overlap. An intervening rule whose subject compound shares no class, ID, or type with the group’s (e.g., .btn:hover between two .card occurrences) no longer blocks a merge. Almost always right with BEM-style class naming—but nothing stops one element from carrying both classes, which is why this isn’t provable. Anything the heuristic can’t read confidently (escapes, :is()/:not() and friends) still blocks.
Rounding-based color equivalences. hsl(0 0% 100%) ≡ #fff, and percentage rgb() channels (rgb(100% 0% 0%) ≡ #f00)—excluded by default because the equivalence goes through browser rounding rather than being purely textual.
Rounding-based angle equivalences. <angle> values canonicalize to degrees: 90deg ≡ 0.25turn ≡ 100grad. grad/turn convert exactly, but rad involves π, so any non-zero rad value is rounded to compare—the whole feature is gated behind the flag rather than splitting it by which unit pair happens to be exact.
Property aliases. word-wrap/overflow-wrap and grid-gap/gap (plus the row/column variants) are pure synonyms in current browsers, so their duplicates merge—keeping the last occurrence’s spelling, which changes the legacy-support surface (a browser old enough to know only word-wrap loses the declaration when overflow-wrap is the spelling kept).
What aggressive mode deliberately does not do: drop same-rule overrides with differing values (color: red; color: oklch(…)). That pattern is CSS’s fallback mechanism for progressive enhancement, and there is no way to tell an intentional fallback from an accident. (Overrides that are really the same color spelled two ways—color: #fff; color: hsl(0 0% 100%)—do collapse, via the color equivalence above.)
The byte economics don’t change with the flag—aggressive mode just unlocks more merges, each carrying the same trade-off between the declaration removed and the selector-list bytes added. Cross-block merges usually save, since they remove whole rules or blocks; declaration-only merges between rules with long selectors can grow the file, so --aggressive can also tip a style sheet further into growth. Either way, the usual growth notes call it out—including in the parenthetical previews shown when the flag is off.
--aggressive deliberately doesn’t imply --fix: The two flags are orthogonal—--aggressive sets how much risk to accept, --fix whether to write. On its own, --aggressive widens report mode (aggressive equivalences surface as findings, the savings estimate includes the aggressive merges), which provides the preview needed for merges that carry risk (add --fix to apply them). Since these merges are not provable, review the diff and test the affected pages after an aggressive --fix—the CLI reminds you, counting how many of the merges actually rode on the flag. Conversely, without the flag, reports and --fix runs note in parentheses what --aggressive would add.
You might like some of my other work:
FAQs
CSS declaration deduplicator for maintainability and performance optimization
The npm package css-dedup receives a total of 614 weekly downloads. As such, css-dedup popularity was classified as not popular.
We found that css-dedup demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
Five frontier LLMs generated the same nonexistent package names, leaving 53 available for potential slopsquatting across PyPI and npm.

Security News
The White House’s Gold Eagle Initiative aims to coordinate AI-discovered vulnerabilities, validate findings, and accelerate patching across critical software.

Security News
A Shai-Hulud infection exposed Suno's source code, which shows the AI music startup stream-ripped tracks to train its models.