🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

css-dedup

Package Overview
Dependencies
Maintainers
1
Versions
10
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

css-dedup - npm Package Compare versions

Comparing version
1.2.1
to
1.3.0
+21
LICENSE.txt
MIT License
Copyright 2026 Jens Oliver Meiert
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+116
-25

@@ -6,3 +6,3 @@ #!/usr/bin/env node

import { parseArgs, styleText } from 'node:util';
import { resolve, join, extname } from 'node:path';
import { resolve, relative, join, extname, sep } from 'node:path';
import { pathToFileURL } from 'node:url';

@@ -15,12 +15,34 @@ import { analyze, dedup } from '../src/index.js';

// Shared between `parseArgs` below and the single-dash guard that runs
// before it—one definition, so an option added here can’t drift out of
// sync with a separately maintained name list
const OPTIONS_CONFIG = {
fix: { type: 'boolean', short: 'f', default: false },
aggressive: { type: 'boolean', short: 'a', default: false },
'savings-only': { type: 'boolean', short: 's', default: false },
'ignore-selector': { type: 'string', short: 'i', multiple: true, default: [] },
'ignore-path': { type: 'string', short: 'p', multiple: true, default: [] },
'no-ignore-selectors-defaults': { type: 'boolean', short: 'n', default: false },
config: { type: 'string', short: 'c' },
help: { type: 'boolean', short: 'h', default: false },
};
// A single-dash spelling of a long option name (`-fix` for `--fix`) isn’t a
// typo `parseArgs` below rejects: With `strict: true`, it only rejects
// letters that don’t resolve to some short flag, so it silently reads
// `-fix` as the boolean `-f` plus `-i` (`--ignore-selector`) with the
// attached value `"x"`—consolidation quietly runs with a bogus selector
// filter instead of failing loudly. Catch the exact-spelling case before
// `parseArgs` gets a chance to cluster it.
for (const arg of process.argv.slice(2)) {
if (!arg.startsWith('-') || arg.startsWith('--')) continue;
const name = arg.slice(1);
if (Object.hasOwn(OPTIONS_CONFIG, name)) {
console.error(`Unknown option \`${arg}\`. Did you mean \`--${name}\`? (A single dash groups letters as short flags instead—e.g., \`-i\` takes an attached value—so '${arg}' doesn’t parse as that long option.)`);
process.exit(1);
}
}
const { values, positionals } = parseArgs({
options: {
fix: { type: 'boolean', short: 'f', default: false },
aggressive: { type: 'boolean', short: 'a', default: false },
'savings-only': { type: 'boolean', short: 's', default: false },
'ignore-selector': { type: 'string', short: 'i', multiple: true, default: [] },
'no-ignore-selectors-defaults': { type: 'boolean', short: 'n', default: false },
config: { type: 'string', short: 'c' },
help: { type: 'boolean', short: 'h', default: false },
},
options: OPTIONS_CONFIG,
allowPositionals: true,

@@ -43,2 +65,3 @@ strict: true,

-i, --ignore-selector <pattern> Regular expression for selectors to exclude from analysis (repeatable)
-p, --ignore-path <pattern> Regular expression tested against each file’s path, relative to the working directory; a match excludes the file (repeatable)
-n, --no-ignore-selectors-defaults Disable the built-in selector-hack ignore list (vendor-prefixed pseudo-elements, IE hacks)

@@ -97,4 +120,11 @@ -c, --config <path> Path to a config file (defaults to \`css-dedup.config.js\` in the working directory, if present)

// plain files pass through as-is, a directory recurses into its .css
// files (sorted, for stable output across runs)
async function expandTargets(targets) {
// files (sorted, for stable output across runs); `ignorePathPatterns` then
// filters the combined list, so an explicit file argument is excluded the
// same way a directory-discovered one is, matched against the path relative
// to the working directory (portable across machines, unlike an absolute one).
// Returns `discovered` alongside the filtered `files` so the caller can
// tell “nothing under these targets” apart from “everything under these
// targets got excluded”—two different situations that deserve two
// different error messages.
async function expandTargets(targets, ignorePathPatterns) {
const expanded = [];

@@ -114,5 +144,46 @@

return expanded;
if (!ignorePathPatterns.length) return { files: expanded, discovered: expanded.length };
// Normalize to `/` before testing, regardless of host OS
const files = expanded.filter(file => file === '-' || !ignorePathPatterns.some(pattern => pattern.test(relative(process.cwd(), file).split(sep).join('/'))));
return { files, discovered: expanded.length };
}
// A `/*# sourceMappingURL=… */` comment means a build tool generated this
// file alongside a source map; `--fix` rewrites the CSS text without
// touching (or regenerating) that map, so its line/column data goes stale—
// worth a one-line heads-up rather than a silently drifting map
const RE_SOURCE_MAP = /\/\*#\s*sourceMappingURL=/;
// Concurrency cap for `prefetchContents()` below
const CONCURRENCY_READ = 8;
// Reads every non-STDIN target concurrently, ahead of the (sequential)
// per-file processing loop in `main()`—so disk I/O for file N+1 overlaps
// with the parsing/analysis CPU work for file N, instead of each file’s
// read waiting behind the previous file’s full report. Outcomes are
// captured rather than thrown, so a read failure still surfaces through
// `processTarget`’s existing per-file error message, one file at a time,
// in the files' original order.
async function prefetchContents(files) {
const contents = new Array(files.length);
let next = 0;
async function worker() {
while (next < files.length) {
const index = next++;
const file = files[index];
if (file === '-') continue;
try {
contents[index] = { css: await readFile(resolve(file), 'utf8') };
} catch (err) {
contents[index] = { err };
}
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY_READ, files.length) }, worker));
return contents;
}
function formatBytesSummary({ before, after, saved }) {

@@ -176,3 +247,3 @@ const percent = before ? (saved / before) * 100 : 0;

// pipeable, the usual status/summary lines go to STDERR rather than STDOUT.
async function processTarget(file, options, { multi }) {
async function processTarget(file, options, { multi }, preread) {
const isStdin = file === '-';

@@ -184,7 +255,15 @@ const label = isStdin ? '(stdin)' : resolve(file);

let css;
try {
css = isStdin ? await readStdin() : await readFile(label, 'utf8');
} catch (err) {
console.error(styleText('red', `Could not read ${label}: ${err.message}`));
return true;
if (preread) {
if (preread.err) {
console.error(styleText('red', `Could not read ${label}: ${preread.err.message}`));
return true;
}
css = preread.css;
} else {
try {
css = isStdin ? await readStdin() : await readFile(label, 'utf8');
} catch (err) {
console.error(styleText('red', `Could not read ${label}: ${err.message}`));
return true;
}
}

@@ -275,3 +354,3 @@

// STDOUT must always carry the complete stylesheet for STDIN input—
// STDOUT must always carry the complete style sheet for STDIN input—
// even with nothing consolidated (or everything withheld), a pipeline

@@ -308,2 +387,5 @@ // consuming it would otherwise receive nothing and lose the CSS entirely

if (!isStdin) log(`Wrote ${label}`);
if (RE_SOURCE_MAP.test(css)) {
log(styleText('yellow', `Note: ${isStdin ? 'this style sheet' : label} references a source map (\`sourceMappingURL\`); \`--fix\` doesn’t regenerate it, so the map is now stale.`));
}
}

@@ -313,3 +395,3 @@ // What `--aggressive` would actually change on disk, measured against

// `savingsOnly` gate as this run, so an aggressive result the re-run
// would withhold compares equal to the untouched stylesheet and earns
// would withhold compares equal to the untouched style sheet and earns
// no hint

@@ -361,3 +443,3 @@ if (potential && potential.css !== output) {

// Summary and `--fix` payoff close each stylesheet’s report, so with
// Summary and `--fix` payoff close each style sheet’s report, so with
// several files it’s unambiguous which file they refer to

@@ -405,6 +487,14 @@ console.log(`${styleText('bold', 'Summary:')} ${findings.length} finding${findings.length !== 1 ? 's' : ''}`);

};
const ignorePathPatterns = [
...(config.ignorePaths ?? []),
...values['ignore-path'].map(pattern => new RegExp(pattern, 'i')),
];
const files = await expandTargets(positionals);
const { files, discovered } = await expandTargets(positionals, ignorePathPatterns);
if (!files.length) {
console.error(`No \`.css\` files found under ${positionals.join(', ')}.`);
if (discovered > 0) {
console.error(`All ${discovered} \`.css\` file${discovered !== 1 ? 's' : ''} found under ${positionals.join(', ')} ${discovered !== 1 ? 'were' : 'was'} excluded by \`--ignore-path\`.`);
} else {
console.error(`No \`.css\` files found under ${positionals.join(', ')}.`);
}
process.exit(1);

@@ -414,2 +504,3 @@ }

const multi = files.length > 1;
const prefetched = await prefetchContents(files);
let failed = false;

@@ -421,3 +512,3 @@

if (multi && index > 0) console.log('\n');
if (await processTarget(file, options, { multi })) failed = true;
if (await processTarget(file, options, { multi }, prefetched[index])) failed = true;
}

@@ -424,0 +515,0 @@

+3
-2

@@ -9,3 +9,3 @@ {

},
"description": "CSS declaration de-duplicator for maintainability and performance optimization",
"description": "CSS declaration deduplicator for maintainability and performance optimization",
"devDependencies": {

@@ -49,2 +49,3 @@ "@eslint/js": "^10.0.1",

],
"license": "MIT",
"name": "css-dedup",

@@ -65,3 +66,3 @@ "repository": {

"types": "src/index.d.ts",
"version": "1.2.1"
"version": "1.3.0"
}
+21
-10

@@ -1,2 +0,2 @@

# CSS Dedup, the CSS Declaration De-Duplicator
# CSS Dedup, the CSS Declaration Deduplicator

@@ -7,2 +7,4 @@ [![npm version](https://img.shields.io/npm/v/css-dedup.svg)](https://www.npmjs.com/package/css-dedup) [![Build status](https://github.com/j9t/css-dedup/workflows/Tests/badge.svg)](https://github.com/j9t/css-dedup/actions) [![Socket](https://badge.socket.dev/npm/package/css-dedup)](https://socket.dev/npm/package/css-dedup) [![GitHub Sponsors](https://badgen.net/static/Support/Open%20Source/cyan)](https://github.com/j9t/css-dedup?sponsor=1)

**Note: CSS Dedup is still brand-new 🆕 and needs to be battle-tested 🧪. Please [report any issues](https://github.com/j9t/css-dedup/issues).**
## Example Optimization

@@ -62,3 +64,3 @@

Since duplicate declarations cost bytes wherever they live—in the stylesheet itself, and (uncompressed) over the wire—the byte counts reflect two payoffs at once: less to maintain, and less to transfer.
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.

@@ -69,3 +71,3 @@ 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 call you can make consciously—it may be worth it if you value using declarations just once (maintainability), 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.)

### CLI Use
### CLI

@@ -84,2 +86,3 @@ ```shell

| `--ignore-selector <pattern>`, `-i` | Regular expression for selectors to exclude from analysis (repeatable) |
| `--ignore-path <pattern>`, `-p` | Regular expression tested against each file’s path, relative to the working directory; a match excludes the file (repeatable) |
| `--no-ignore-selectors-defaults`, `-n` | Disable the built-in selector-hack ignore list |

@@ -89,3 +92,3 @@ | `--config <path>`, `-c <path>` | Path to a config file (defaults to `css-dedup.config.js` in the working directory, if present) |

`--ignore-selector` is singular because it’s a repeatable flag—each occurrence (`-i pattern1 -i pattern2`) adds one pattern. The corresponding programmatic option, `ignoreSelectors`, takes an array.
`--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, by default).

@@ -96,2 +99,4 @@ 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.

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.
### Config File

@@ -108,2 +113,3 @@

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\//]
aggressive: false, // set to `true` to also allow probably-safe merges

@@ -114,3 +120,3 @@ savingsOnly: false // set to `true` to skip files whose consolidation would grow them (`--fix` runs only)

CLI flags layer on top of the config file rather than replacing it: `--ignore-selector` patterns are added to `ignoreSelectors` from the config, 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 never writes anyway); on `--fix` runs it decides whether the file is written.
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 never writes anyway); on `--fix` runs it decides whether the file is written.

@@ -136,3 +142,3 @@ ### Programmatic Use

aggressive: false, // set to `true` to also allow probably-safe merges
savingsOnly: false, // set to `true` to withhold a consolidation that would grow the stylesheet (`dedup()` only)
savingsOnly: false, // set to `true` to withhold a consolidation that would grow the style sheet (`dedup()` only)
}

@@ -154,3 +160,3 @@ ```

`dedup()` returns `{ css, applied, skipped, bytes }`: `css` is the rewritten stylesheet; `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 stylesheet 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`.
`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`.

@@ -182,3 +188,3 @@ ### PostCSS Plugin Use

2. …**scopes** rules by their DRY boundary—the root stylesheet, the contents of an `@media`/`@supports`/`@layer` condition, or one specific nested rule (native CSS nesting).
2. …**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).
- Declarations are only ever compared within the same scope: A rule’s own declarations are never compared against those of rules nested inside it, and rules in different `@layer`s (or different `@media`/`@supports` conditions) can’t share a merged rule.

@@ -201,2 +207,5 @@ - For _reporting_, two blocks with the _same_ condition are the same scope even when written separately in the source (e.g., two `@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.

- Treats the `border`/`outline` `none` and `0` values as equivalent.
- Canonicalizes `<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.
- Sorts `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).
- In [aggressive mode](#aggressive-mode) only: Canonicalizes `<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.

@@ -220,3 +229,3 @@ 5. …**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.

`test/fixtures/*.css` contains small example stylesheets 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.
`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.

@@ -233,2 +242,4 @@ ### Aggressive Mode

* **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).

@@ -238,3 +249,3 @@

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 stylesheet further into growth. Either way, the usual growth notes call it out—including in the parenthetical previews shown when the flag is off.
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.

@@ -241,0 +252,0 @@ `--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 is exactly the preview you want for the 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.

@@ -27,3 +27,3 @@ import postcss from 'postcss';

// A “scope” is a DRY boundary: the root stylesheet, or the direct contents of
// A “scope” is a DRY boundary: the root style sheet, or the direct contents of
// one specific `@media`/`@supports`/`@layer`/etc. condition, or one specific

@@ -321,3 +321,3 @@ // selector used as a nesting host (native CSS nesting). This computes the

// Detects whether this stylesheet predominantly writes multi-selector rules
// Detects whether this style sheet predominantly writes multi-selector rules
// one selector per line (`.a,\n.b {}`) or comma-separated on one line

@@ -372,3 +372,3 @@ // (`.a, .b {}`), by tallying the existing multi-selector rules already in

// The `savingsOnly` gate: Consolidation runs on a detached clone first, and
// only a result that doesn’t grow the stylesheet is grafted back onto the
// only a result that doesn’t grow the style sheet is grafted back onto the
// real root—so a growing result leaves the root untouched (“withheld”),

@@ -404,3 +404,3 @@ // which is what lets the PostCSS plugin and the CLI share one

// The subject-identity memoization is per run: fresh here, reused across
// this run’s fixed-point passes, never carried over to the next stylesheet
// this run’s fixed-point passes, never carried over to the next style sheet
resetSubjectIdentities();

@@ -407,0 +407,0 @@

@@ -151,2 +151,155 @@ import { normalizeColors } from './colors.js';

// Multiplies a decimal string by `numerator / 10**denominatorPow10`,
// exactly—used below for unit conversions whose ratio is rational (time:
// ×1000/1; angle `turn`: ×360/1; angle `grad`: ×9/10). `Number(text) * ratio`
// can’t be trusted here: `1.005 * 1000` is `1004.9999999999999` in IEEE 754,
// which would make two textually exact-equal times compare unequal. Doing
// the multiplication on the digit string via `BigInt`, then relocating the
// decimal point, is exact for any finite decimal—`denominatorPow10` only
// ever divides by a power of ten, so that step is point relocation, not
// true division. Matches a result of all zeros (`0`, `0.0`, …)—used below to
// drop a negative sign `scaleDecimalExact` would otherwise carry over from a
// negative-zero input (`-0`, `-0.0`).
const RE_ALL_ZERO = /^0\.?0*$/;
function scaleDecimalExact(text, numerator, denominatorPow10 = 0) {
const negative = text.startsWith('-');
const unsigned = negative ? text.slice(1) : text;
const [intPart, fracPart = ''] = unsigned.split('.');
const scaled = (BigInt((intPart || '0') + fracPart || '0') * BigInt(numerator)).toString();
const decimalPlaces = fracPart.length + denominatorPow10;
const padded = scaled.padStart(decimalPlaces + 1, '0');
const pointIndex = padded.length - decimalPlaces;
const result = decimalPlaces > 0 ? `${padded.slice(0, pointIndex)}.${padded.slice(pointIndex)}` : padded;
return (negative && !RE_ALL_ZERO.test(result) ? '-' : '') + result;
}
// A number token must not be preceded by an identifier character, either—
// without this, `RE_TIME`/`RE_ANGLE` below would match `2s` inside a
// case-sensitive custom ident like `animation-name: fade2s` (a real
// `@keyframes` name) and silently rewrite it to `fade2000ms`, corrupting
// the identifier and risking a false duplicate (or, in `--fix`, an unsafe
// merge) against an unrelated animation that happens to share the
// resulting spelling
const RE_NUMBER = '(?<![\\w-])(-?(?:\\d+(?:\\.\\d+)?|\\.\\d+))';
// Canonicalizes time values to milliseconds for comparison: `1s` and
// `1000ms` are exactly interchangeable per the CSS `<time>` grammar, and
// converting `s` to `ms` is only ever a decimal-point shift—never lossy—so
// this runs unconditionally, the same way zero-value units do. Matches
// regardless of surrounding property (including inside the `transition`/
// `animation` shorthand, alongside a case-sensitive `animation-name`)—see
// `RE_NUMBER`’s left-boundary check for why this can’t collide with an
// actual identifier.
const RE_TIME = new RegExp(`${RE_NUMBER}(ms|s)\\b`, 'gi');
function normalizeTimeUnits(value) {
return value.replace(RE_TIME, (match, number, unit) => {
if (unit.toLowerCase() === 'ms') return match;
return `${scaleDecimalExact(number, 1000)}ms`;
});
}
// Canonicalizes angle values to degrees for comparison—`aggressive` mode
// only. `grad` → `deg` (×9/10) and `turn` → `deg` (×360) are exact rational
// conversions, but `rad` → `deg` (×180/π) involves an irrational factor: Any
// non-zero `rad` value becomes a non-terminating decimal in degrees, so
// that one conversion is rounded. Rather than split the feature by
// per-unit exactness, the whole thing is gated behind `aggressive`, the
// same “probably, not provably, safe” treatment the `hsl()` color
// equivalence elsewhere gets.
const RE_ANGLE = new RegExp(`${RE_NUMBER}(deg|grad|rad|turn)\\b`, 'gi');
const ANGLE_ROUND_DECIMALS = 6;
function normalizeAngleUnits(value) {
return value.replace(RE_ANGLE, (match, number, rawUnit) => {
const unit = rawUnit.toLowerCase();
if (unit === 'deg') return match;
if (unit === 'grad') return `${scaleDecimalExact(number, 9, 1)}deg`;
if (unit === 'turn') return `${scaleDecimalExact(number, 360)}deg`;
return `${(Number(number) * (180 / Math.PI)).toFixed(ANGLE_ROUND_DECIMALS)}deg`;
});
}
// Splits a value on top-level commas only—reused below by the `min()`/
// `max()` argument sorter; a comma inside a nested call used as an
// argument (`min(calc(1px, 2px), 3px)`) doesn’t separate top-level ones
function splitTopLevelCommas(text) {
const parts = [];
let depth = 0;
let current = '';
for (const char of text) {
if (char === '(') depth++;
if (char === ')') depth = Math.max(0, depth - 1);
if (char === ',' && depth === 0) {
parts.push(current);
current = '';
continue;
}
current += char;
}
parts.push(current);
return parts;
}
// A character CSS identifiers (including function names) can continue
// with—used below to require a real token boundary before `min(`/`max(`,
// so a hypothetical custom ident merely ending in those letters is never
// mistaken for the function (a plain `\b` wouldn’t reject a hyphenated one)
const RE_IDENT_CHAR = /[\w-]/;
// Tested once per scanned character in `sortMinMaxArguments()` below—hoisted
// so the loop doesn’t recompile the same literal on every iteration
const RE_MIN_MAX_START = /^(min|max)\(/i;
// Sorts a top-level `min()`/`max()` call’s comma-separated arguments
// canonically—mathematical min/max is commutative, so argument order
// carries no meaning (`min(100%, 500px)` ≡ `min(500px, 100%)`). `clamp()`
// is deliberately left alone: its three arguments are positional (minimum,
// preferred, maximum), and reordering them changes what it computes.
// `minmax()` (grid track sizing—also positional) is never matched, since
// the scan looks for the function name immediately followed by `(`,
// and “minmax(” doesn’t contain “min(” or “max(” as a prefix.
function sortMinMaxArguments(value) {
let result = '';
let index = 0;
while (index < value.length) {
const boundaryOk = index === 0 || !RE_IDENT_CHAR.test(value[index - 1]);
const match = boundaryOk ? RE_MIN_MAX_START.exec(value.slice(index)) : null;
if (!match) {
result += value[index];
index++;
continue;
}
const name = match[1];
const argsStart = index + match[0].length;
let depth = 1;
let cursor = argsStart;
while (cursor < value.length && depth > 0) {
if (value[cursor] === '(') depth++;
else if (value[cursor] === ')') depth--;
cursor++;
}
// Unbalanced parentheses: Leave the rest of the value untouched rather
// than guessing where the call would have ended
if (depth > 0) {
result += value.slice(index);
break;
}
const inner = value.slice(argsStart, cursor - 1);
const args = splitTopLevelCommas(inner).map(sortMinMaxArguments);
args.sort();
result += `${name}(${args.join(',')})`;
index = cursor;
}
return result;
}
// Property aliases—legacy names current browsers treat as pure synonyms of

@@ -230,3 +383,11 @@ // their standardized successors. Only folded in aggressive mode: The two

if (!ZERO_PERCENT_SENSITIVE_PROPS.has(propNormalized)) value = value.replace(RE_ZERO_PERCENT, '0');
value = normalizeTimeUnits(value);
if (aggressive) value = normalizeAngleUnits(value);
// Both unit conversions above produce their own freshly-scaled decimal
// text (e.g. `0.3s` → `300.0ms`), so the general decimal cleanup runs
// after them, not before—and the `min()`/`max()` sort runs after that,
// so two arguments that are the same value in different raw spellings
// (`.5`/`0.50`) already compare equal as sort keys
value = normalizeDecimals(value);
value = sortMinMaxArguments(value);
value = reduceShorthandRepetition(propNormalized, value);

@@ -233,0 +394,0 @@

@@ -15,3 +15,3 @@ import { analyzeRoot, dedupRoot } from './index.js';

if (withheld) {
root.warn(result, `Consolidation withheld (\`savingsOnly\`): ${withheld.count} merge${withheld.count !== 1 ? 's' : ''} would make the stylesheet ${Math.abs(withheld.bytes.saved)} bytes bigger`);
root.warn(result, `Consolidation withheld (\`savingsOnly\`): ${withheld.count} merge${withheld.count !== 1 ? 's' : ''} would make the style sheet ${Math.abs(withheld.bytes.saved)} bytes bigger`);
}

@@ -18,0 +18,0 @@ for (const item of skipped) {