+138
-20
@@ -9,2 +9,3 @@ #!/usr/bin/env node | ||
| import { analyze, dedup } from '../src/index.js'; | ||
| import { declarationKey } from '../src/normalization.js'; | ||
@@ -17,2 +18,4 @@ // Directories skipped when recursing into a target directory | ||
| 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: [] }, | ||
@@ -37,2 +40,4 @@ 'no-ignore-selectors-defaults': { type: 'boolean', short: 'n', default: false }, | ||
| -f, --fix Consolidate declarations that are safe to merge automatically, rewriting each file in place (or printing to STDOUT for \`-\`) | ||
| -a, --aggressive Also allow merges that are probably—but not provably—safe; on its own this widens the report, with \`--fix\` it applies the merges (test afterwards) | ||
| -s, --savings-only With \`--fix\`: Leave a file untouched when its consolidation would make it bigger, not smaller (checked per file) | ||
| -i, --ignore-selector <pattern> Regular expression for selectors to exclude from analysis (repeatable) | ||
@@ -50,2 +55,9 @@ -n, --no-ignore-selectors-defaults Disable the built-in selector-hack ignore list (vendor-prefixed pseudo-elements, IE hacks) | ||
| // A write policy needs a write mode: Report mode never touches a file, so a | ||
| // bare `--savings-only` could only sit inert and mislead | ||
| if (values['savings-only'] && !values.fix) { | ||
| console.error('`--savings-only` only applies together with `--fix` (report mode never writes).'); | ||
| process.exit(1); | ||
| } | ||
| // Settings file | ||
@@ -111,8 +123,9 @@ async function loadConfig(pathConfig) { | ||
| // A merged selector list can cost more bytes than the declaration it removes | ||
| // saves (e.g., two long, single-use selectors sharing one short declaration), | ||
| // so consolidation isn’t always a net win for transfer size—only ever for | ||
| // maintainability (using the declaration just once). Surface that plainly | ||
| // rather than silently reporting negative “savings.” | ||
| function formatGrowth(bytes) { | ||
| // The magnitude of a byte delta as “N bytes (P%)”, sign-blind—used for | ||
| // savings and growth alike, with the direction spelled out in the | ||
| // surrounding sentence. (Growth is real: A merged selector list can cost | ||
| // more bytes than the declaration it removes saves, so consolidation isn’t | ||
| // always a net win for transfer size—only ever for maintainability. Surface | ||
| // that plainly rather than silently reporting negative “savings.”) | ||
| function formatBytesShare(bytes) { | ||
| const percent = bytes.before ? (Math.abs(bytes.saved) / bytes.before) * 100 : 0; | ||
@@ -194,10 +207,71 @@ return `${Math.abs(bytes.saved).toLocaleString()} bytes (${percent.toFixed(1)}%)`; | ||
| // The opposite-mode consolidation of the same source—a second, discarded | ||
| // pass serving as the aggressive preview on default runs, and as the | ||
| // default-mode baseline that measures what rode on the flag on aggressive | ||
| // runs | ||
| function oppositePass(css, targetOptions) { | ||
| return dedup(css, { ...targetOptions, aggressive: !targetOptions.aggressive }); | ||
| } | ||
| // A skipped group’s key as the aggressive pass would spell it: Aggressive | ||
| // normalization can rewrite the default spelling (`hsl()` onto hex, | ||
| // `word-wrap` onto `overflow-wrap`), so matching the default spelling alone | ||
| // would hint at groups the aggressive pass also skips. Selector-list keys | ||
| // (blocked same-selector folds) carry no `prop: value` shape and pass | ||
| // through unchanged. | ||
| function aggressiveKeySpelling(key) { | ||
| const important = key.endsWith(' !important'); | ||
| const base = important ? key.slice(0, -' !important'.length) : key; | ||
| const separator = base.indexOf(': '); | ||
| if (separator === -1) return key; | ||
| return declarationKey(base.slice(0, separator), base.slice(separator + 2), important, true); | ||
| } | ||
| // The scope + key identities of the groups the aggressive pass still skipped, | ||
| // as one Set—so the “may merge” hint check below is a lookup, not a scan of | ||
| // the whole skipped list per printed line | ||
| function skippedWithAggressive(potential) { | ||
| return potential ? new Set(potential.skipped.map(item => `${item.scope}\0${item.key}`)) : null; | ||
| } | ||
| // One skipped-group line, for fix and report mode alike—with the “may merge | ||
| // with `--aggressive`” hint when the aggressive pass didn’t skip the group, | ||
| // matched under both the default and the aggressive spelling of the key, so | ||
| // a respelled key never produces a false hint | ||
| function formatSkippedLine(item, skippedAggressive) { | ||
| const stillSkipped = !skippedAggressive | ||
| || skippedAggressive.has(`${item.scope}\0${item.key}`) | ||
| || skippedAggressive.has(`${item.scope}\0${aggressiveKeySpelling(item.key)}`); | ||
| const hint = stillSkipped ? '' : ` (may merge with \`--aggressive\`)`; | ||
| return ` ${styleText('dim', item.scope === 'root' ? '(root)' : item.scope)} ${item.key} — ${item.reason}${hint}`; | ||
| } | ||
| async function processCss(css, targetOptions, { isStdin, label }) { | ||
| const potential = targetOptions.aggressive ? null : oppositePass(css, targetOptions); | ||
| const skippedAggressive = skippedWithAggressive(potential); | ||
| if (values.fix) { | ||
| const { css: output, applied, skipped, bytes } = dedup(css, targetOptions); | ||
| // `savingsOnly` is the engine’s gate (see `dedupRoot()`): A withheld | ||
| // result arrives as the untouched style sheet, with `applied` empty and | ||
| // the declined outcome under `withheld` | ||
| const { css: output, applied, skipped, bytes, withheld } = dedup(css, targetOptions); | ||
| const log = isStdin ? console.error : console.log; | ||
| // Whether anything actually rode on the flag—measured by comparing | ||
| // output against a discarded default-mode pass, never by entry counts: | ||
| // One aggressive cross-block or alias fold can absorb what the default | ||
| // pass would have done in more, separate merges, so a count delta can be | ||
| // zero or negative on a run whose merges were entirely aggressive-only. | ||
| // The count survives only as the message’s detail, where it’s positive. | ||
| let aggressiveDiffers = false; | ||
| let aggressiveOnly = 0; | ||
| if (targetOptions.aggressive && applied.length) { | ||
| const baseline = oppositePass(css, targetOptions); | ||
| aggressiveDiffers = output !== baseline.css; | ||
| aggressiveOnly = Math.max(applied.length - baseline.applied.length, 0); | ||
| } | ||
| // STDOUT must always carry the complete stylesheet for STDIN input— | ||
| // even with nothing consolidated, a pipeline consuming it would | ||
| // otherwise receive nothing and lose the CSS entirely | ||
| // even with nothing consolidated (or everything withheld), a pipeline | ||
| // consuming it would otherwise receive nothing and lose the CSS entirely | ||
| if (isStdin) { | ||
@@ -210,5 +284,9 @@ process.stdout.write(output); | ||
| const skippedNote = skipped.length ? ' (considered unsafe to auto-merge)' : ''; | ||
| log(`${styleText('green', `${applied.length} consolidated`)}, ${styleText('yellow', `${skipped.length} skipped`)}${skippedNote}`); | ||
| if (withheld) { | ||
| log(`${styleText('green', '0 consolidated')}, ${styleText('yellow', `${withheld.count} withheld`)} (consolidating would make the file ${formatBytesShare(withheld.bytes)} bigger—\`--savings-only\`), ${styleText('yellow', `${skipped.length} skipped`)}${skippedNote}`); | ||
| } else { | ||
| log(`${styleText('green', `${applied.length} consolidated`)}, ${styleText('yellow', `${skipped.length} skipped`)}${skippedNote}`); | ||
| } | ||
| for (const item of skipped) { | ||
| log(` ${styleText('dim', item.scope === 'root' ? '(root)' : item.scope)} ${item.key} — ${item.reason}`); | ||
| log(formatSkippedLine(item, skippedAggressive)); | ||
| } | ||
@@ -218,8 +296,26 @@ if (applied.length) { | ||
| if (bytes.saved < 0) { | ||
| log(styleText('yellow', `Note: this consolidation makes the file ${formatGrowth(bytes)} bigger, not smaller—the merged selector list costs more than the removed declaration(s) save. Still worth doing for maintainability (using each declaration just once); skip \`--fix\` here if you care more about transfer size.`)); | ||
| log(styleText('yellow', `Note: this consolidation makes the file ${formatBytesShare(bytes)} bigger, not smaller—the merged selector list costs more than the removed declaration(s) save. Still worth doing for maintainability (using each declaration just once); skip \`--fix\` here if you care more about transfer size.`)); | ||
| } | ||
| if (!isStdin) log(`Wrote ${label}`); | ||
| } | ||
| // What `--aggressive` would actually change on disk, measured against | ||
| // this run’s real outcome: `potential` went through the same | ||
| // `savingsOnly` gate as this run, so an aggressive result the re-run | ||
| // would withhold compares equal to the untouched stylesheet and earns | ||
| // no hint | ||
| if (potential && potential.css !== output) { | ||
| const extra = potential.applied.length - applied.length; | ||
| const extraSaved = bytes.after - potential.bytes.after; | ||
| const savings = extraSaved > 0 ? `, saving another ${extraSaved.toLocaleString()} bytes` | ||
| : extraSaved < 0 ? `, though growing the file by ${Math.abs(extraSaved).toLocaleString()} bytes` : ''; | ||
| log(`(Re-running with \`--aggressive\` would consolidate ${extra > 0 ? `${extra} more` : 'further'}${savings}.)`); | ||
| } | ||
| if (aggressiveDiffers) { | ||
| const share = aggressiveOnly > 0 | ||
| ? `${aggressiveOnly} of these merges ${aggressiveOnly !== 1 ? 'are' : 'is'}` | ||
| : 'Some of these merges are'; | ||
| log(styleText('yellow', `${share} aggressive-only—probably, but not provably, safe. Review the diff and test the affected pages.`)); | ||
| } | ||
| return skipped.length > 0; | ||
| return skipped.length > 0 || Boolean(withheld); | ||
| } | ||
@@ -230,3 +326,6 @@ | ||
| if (!findings.length) { | ||
| console.log('No duplicate declarations found.'); | ||
| const note = potential?.applied.length | ||
| ? ` With \`--aggressive\`: ${potential.applied.length} consolidation${potential.applied.length !== 1 ? 's' : ''} possible.` | ||
| : ''; | ||
| console.log(`No duplicate declarations found.${note}`); | ||
| return false; | ||
@@ -239,3 +338,3 @@ } | ||
| // as `--fix`, just discarded instead of written | ||
| const { applied, skipped, bytes } = dedup(css, targetOptions); | ||
| const { css: cssDryRun, applied, skipped, bytes, withheld } = dedup(css, targetOptions); | ||
@@ -249,3 +348,3 @@ // Findings above don't distinguish safe from unsafe—without this, a | ||
| for (const item of skipped) { | ||
| console.log(` ${styleText('dim', item.scope === 'root' ? '(root)' : item.scope)} ${item.key} — ${item.reason}`); | ||
| console.log(formatSkippedLine(item, skippedAggressive)); | ||
| } | ||
@@ -258,10 +357,27 @@ console.log(''); | ||
| console.log(`${styleText('bold', 'Summary:')} ${findings.length} finding${findings.length !== 1 ? 's' : ''}`); | ||
| if (applied.length) { | ||
| if (withheld) { | ||
| // The dry run went through the engine’s `savingsOnly` gate (set via the | ||
| // config file—the CLI flag itself requires `--fix`), so `--fix` here | ||
| // would decline to write; say so instead of promising a change | ||
| console.log(styleText('yellow', `\`--fix\` would leave this file untouched—\`savingsOnly\` is set, and consolidating would make it ${formatBytesShare(withheld.bytes)} bigger.`)); | ||
| } else if (applied.length) { | ||
| if (bytes.saved > 0) { | ||
| const percent = bytes.before ? (bytes.saved / bytes.before) * 100 : 0; | ||
| console.log(`Run with \`--fix\` to save ${bytes.saved.toLocaleString()} bytes (${percent.toFixed(1)}%).`); | ||
| console.log(`Run with \`--fix\` to save ${formatBytesShare(bytes)}.`); | ||
| } else if (bytes.saved < 0) { | ||
| console.log(styleText('yellow', `Running \`--fix\` here would make the file ${formatGrowth(bytes)} bigger, not smaller (worth it for maintainability but not for transfer size).`)); | ||
| console.log(styleText('yellow', `Running \`--fix\` here would make the file ${formatBytesShare(bytes)} bigger, not smaller (worth it for maintainability but not for transfer size).`)); | ||
| } | ||
| } | ||
| // Gated on the outputs differing, never on entry counts: One aggressive | ||
| // cross-block or alias fold can absorb what the default pass would have | ||
| // done in more, separate merges, so a count delta can be zero or negative | ||
| // on exactly the files where `--aggressive` changes (and saves) the most | ||
| if (potential && potential.css !== cssDryRun) { | ||
| const extra = potential.applied.length - applied.length; | ||
| const totals = potential.bytes.saved > 0 | ||
| ? `, saving ${formatBytesShare(potential.bytes)} in total` | ||
| : potential.bytes.saved < 0 | ||
| ? `, though growing the file by ${formatBytesShare(potential.bytes)} in total` | ||
| : ''; | ||
| console.log(`With \`--fix --aggressive\`: ${extra > 0 ? `${extra} more consolidation${extra !== 1 ? 's' : ''}` : 'further consolidation'}${totals}.`); | ||
| } | ||
@@ -279,2 +395,4 @@ return true; | ||
| ignoreSelectorsDefaults: values['no-ignore-selectors-defaults'] ? false : (config.ignoreSelectorsDefaults ?? true), | ||
| aggressive: values.aggressive || (config.aggressive ?? false), | ||
| savingsOnly: values['savings-only'] || (config.savingsOnly ?? false), | ||
| }; | ||
@@ -281,0 +399,0 @@ |
+1
-1
@@ -53,3 +53,3 @@ { | ||
| "type": "module", | ||
| "version": "1.0.0" | ||
| "version": "1.1.0" | ||
| } |
+38
-12
@@ -63,3 +63,3 @@ # CSS Dedup, the CSS Declaration De-Duplicator | ||
| 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. | ||
| 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.) | ||
@@ -79,2 +79,4 @@ ## Usage | ||
| | `--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](#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 never writes | | ||
| | `--ignore-selector <pattern>`, `-i` | Regular expression for selectors to exclude from analysis (repeatable) | | ||
@@ -87,3 +89,3 @@ | `--no-ignore-selectors-defaults`, `-n` | Disable the built-in selector-hack ignore list | | ||
| 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) in any of the given files. | ||
| 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. | ||
@@ -94,13 +96,17 @@ 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. | ||
| 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): | ||
| 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, shown with their defaults (each can be omitted): | ||
| ```javascript | ||
| // css-dedup.config.js | ||
| export default { | ||
| ignoreSelectors: [/^\.legacy-/], | ||
| ignoreSelectorsDefaults: true | ||
| ignoreSelectors: [], // additional selector patterns to exclude, e.g., [/^\.legacy-/] | ||
| ignoreSelectorsDefaults: true, // set to `false` to disable the built-in hack list | ||
| 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) | ||
| }; | ||
| ``` | ||
| 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. | ||
| 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. | ||
@@ -125,2 +131,4 @@ ### Programmatic Use | ||
| ignoreSelectorsDefaults: true, // set to `false` to disable the built-in hack list | ||
| 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) | ||
| } | ||
@@ -142,3 +150,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. `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 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`. | ||
@@ -162,3 +170,3 @@ ### PostCSS Plugin Use | ||
| The plugin takes the same options as `analyze()`/`dedup()`, plus `fix: true` to switch it into consolidation mode. 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 duplicate work those tools do. | ||
| 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 duplicate work those tools do. | ||
@@ -174,3 +182,3 @@ ## How It Works | ||
| - 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. | ||
| - `--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. | ||
| - `--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](#aggressive-mode)). | ||
| - Statement-form at-rules with no block (`@layer reset, base;`) are skipped. | ||
@@ -186,3 +194,3 @@ | ||
| - Collapses redundant decimal zeros (`.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`). | ||
| - Canonicalizes equivalent color spellings: `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. | ||
| - Canonicalizes equivalent color spellings: `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](#aggressive-mode), which accepts the rounding). | ||
| - Treats `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). | ||
@@ -202,3 +210,3 @@ - Collapses repeated shorthand values, following the omission rules in reverse: `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. | ||
| - Removes the declaration from the other occurrences—but only if no other rule between the first and last occurrence also sets that property or a shorthand/longhand overlapping it (`margin` and `margin-left`, `border-color` and `border-top-color`, etc.), for any selector. | ||
| - One narrow exception to “any other rule”: If that rule’s selector is provably mutually exclusive with the group’s—right now, that only covers an exact-match attribute value on the same attribute, on what is provably the same element (`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. “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. | ||
| - One narrow exception to “any other rule”: If that rule’s selector is provably mutually exclusive with the group’s—right now, that only covers an exact-match attribute value on the same attribute, on what is provably the same element (`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](#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. | ||
| - If a merged rule (including the last occurrence itself) also carries a declaration for an overlapping property, that declaration is split out into its own small rule—keeping that occurrence’s own, original selector—placed right after the merged rule, rather than blocking the merge outright: Folding every selector onto one shared declaration block would otherwise hand that overlapping extra to selectors that never had it. Exception: If that extra is itself duplicated elsewhere in the same scope, it’s left alone and the whole merge is skipped instead, since splitting it here would orphan that other duplicate’s own merge. | ||
@@ -210,4 +218,22 @@ - If something does block it, the merge is skipped and reported rather than risking a cascade change. A blocker fences, though—it doesn’t forbid: Occurrences on the same side of it still consolidate among themselves (their own spans are clean, so the same safety argument applies), and the group is reported as skipped either way, since the duplicate keeps existing across the blocker. | ||
| `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`) to see them in action. | ||
| `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. | ||
| ### Aggressive Mode | ||
| 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. | ||
| * **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 stylesheet 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 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. | ||
| *** | ||
@@ -214,0 +240,0 @@ |
+121
-19
@@ -55,9 +55,40 @@ // The CSS named colors (CSS Color Module Level 4), mapped to their six-digit hex | ||
| // Same shape as `RE_RGB_FUNCTION`—only consulted in aggressive mode, | ||
| // since `hsl()` equivalence goes through rounding (see `canonicalizeHsl()`) | ||
| const RE_HSL_FUNCTION = /\bhsla?\(([^()]*)\)/g; | ||
| // A plain 0–255 integer channel (the only form safe mode canonicalizes) | ||
| const RE_INTEGER_CHANNEL = /^\d{1,3}$/; | ||
| // A CSS number, optionally percent-suffixed—what aggressive mode accepts as | ||
| // a channel. Guards the `Number()` call against JavaScript-isms that aren’t | ||
| // CSS numbers (`0x1f`, `Infinity`). | ||
| const RE_NUMBER_CHANNEL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?%?$/i; | ||
| // A hue (a number with an optional angle unit) | ||
| const RE_HUE = /^(-?[\d.]+)(deg|grad|rad|turn)?$/; | ||
| // `#abc` → `#aabbcc`, `#abcd` → `#aabbccdd`; a fully opaque alpha channel is | ||
| // the default, so `…ff` is dropped from an eight-digit form | ||
| function expandHex(hex) { | ||
| const digits = hex.length <= 4 ? [...hex].map(digit => digit + digit).join('') : hex; | ||
| const digits = hex.length <= 4 ? Array.from(hex, digit => digit + digit).join('') : hex; | ||
| return digits.length === 8 && digits.endsWith('ff') ? digits.slice(0, 6) : digits; | ||
| } | ||
| function parseAlpha(raw) { | ||
| const number = raw.endsWith('%') ? Number(raw.slice(0, -1)) / 100 : Number(raw); | ||
| if (!Number.isFinite(number)) return null; | ||
| return Math.min(Math.max(number, 0), 1); | ||
| } | ||
| // One canonical spelling for a set of resolved 0–255 channels plus alpha— | ||
| // shared by the `rgb()` and (aggressive-only) `hsl()` paths, so both land on | ||
| // the same form and compare equal | ||
| function formatChannels(channels, alpha) { | ||
| if (alpha === 1) return `#${channels.map(channel => channel.toString(16).padStart(2, '0')).join('')}`; | ||
| // Fully transparent black is what the `transparent` keyword is defined as | ||
| if (alpha === 0 && channels.every(channel => channel === 0)) return 'transparent'; | ||
| return `rgba(${channels.join(',')},${alpha})`; | ||
| } | ||
| // Canonicalizes one `rgb()`/`rgba()` argument list—legacy comma and modern | ||
@@ -68,24 +99,92 @@ // space syntax alike (whitespace/comma/slash spacing has already been | ||
| // `50%` of 255 is 127.5, and how that rounds is the browser’s business, not | ||
| // a textual equivalence. | ||
| function canonicalizeRgb(args) { | ||
| // a textual equivalence. Aggressive mode accepts the rounding and resolves | ||
| // those channels, too (`Math.round`, matching what current browsers do), | ||
| // clamping out-of-range channels the way the spec does (`rgb(300 0 0)` | ||
| // renders as pure red). | ||
| function canonicalizeRgb(args, aggressive) { | ||
| const parts = args.trim().split(/[\s,/]+/); | ||
| if (parts.length < 3 || parts.length > 4) return null; | ||
| const channels = parts.slice(0, 3).map(part => (/^\d{1,3}$/.test(part) ? Number(part) : null)); | ||
| if (channels.some(channel => channel === null || channel > 255)) return null; | ||
| // Legacy comma syntax requires all three channels to be the same type—a | ||
| // mixed `rgb(50%, 100, 20)` is invalid CSS that browsers drop, so it must | ||
| // never compare equal to a valid color (the merge keeps the shortest | ||
| // spelling, which could be the broken one); the modern space syntax | ||
| // allows mixing | ||
| if (args.includes(',') && new Set(parts.slice(0, 3).map(part => part.endsWith('%'))).size > 1) return null; | ||
| let alpha = 1; | ||
| if (parts.length === 4) { | ||
| const raw = parts[3]; | ||
| const number = raw.endsWith('%') ? Number(raw.slice(0, -1)) / 100 : Number(raw); | ||
| const channels = parts.slice(0, 3).map(part => { | ||
| if (!aggressive) return RE_INTEGER_CHANNEL.test(part) ? Number(part) : null; | ||
| if (!RE_NUMBER_CHANNEL.test(part)) return null; | ||
| const number = part.endsWith('%') ? (Number(part.slice(0, -1)) / 100) * 255 : Number(part); | ||
| if (!Number.isFinite(number)) return null; | ||
| alpha = Math.min(Math.max(number, 0), 1); | ||
| } | ||
| return Math.round(Math.min(Math.max(number, 0), 255)); | ||
| }); | ||
| if (channels.some(channel => channel === null || channel > 255)) return null; | ||
| if (alpha === 1) return `#${channels.map(channel => channel.toString(16).padStart(2, '0')).join('')}`; | ||
| // Fully transparent black is what the `transparent` keyword is defined as | ||
| if (alpha === 0 && channels.every(channel => channel === 0)) return 'transparent'; | ||
| return `rgba(${channels.join(',')},${alpha})`; | ||
| const alpha = parts.length === 4 ? parseAlpha(parts[3]) : 1; | ||
| if (alpha === null) return null; | ||
| return formatChannels(channels, alpha); | ||
| } | ||
| // The CSS Color 4 HSL → RGB algorithm, rounded to 0–255 integer channels— | ||
| // aggressive mode only, since that rounding is exactly the step safe mode | ||
| // refuses to take on the browser’s behalf | ||
| function hslToRgb(hue, saturation, lightness) { | ||
| const sat = saturation / 100; | ||
| const light = lightness / 100; | ||
| const k = n => (n + hue / 30) % 12; | ||
| const a = sat * Math.min(light, 1 - light); | ||
| const channel = n => light - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)); | ||
| return [channel(0), channel(8), channel(4)].map(x => Math.round(x * 255)); | ||
| } | ||
| // A hue is a plain angle; `deg` is the default unit | ||
| function parseHue(raw) { | ||
| const match = RE_HUE.exec(raw); | ||
| if (!match) return null; | ||
| const number = Number(match[1]); | ||
| if (!Number.isFinite(number)) return null; | ||
| const unit = match[2] ?? 'deg'; | ||
| const degrees = unit === 'deg' ? number | ||
| : unit === 'grad' ? number * 0.9 | ||
| : unit === 'rad' ? number * (180 / Math.PI) | ||
| : number * 360; | ||
| return ((degrees % 360) + 360) % 360; | ||
| } | ||
| // Saturation/lightness are percentages (CSS Color 5 also allows the bare | ||
| // number spelling of the same 0–100 scale) | ||
| function parseHslComponent(raw) { | ||
| if (!RE_NUMBER_CHANNEL.test(raw)) return null; | ||
| const number = Number(raw.endsWith('%') ? raw.slice(0, -1) : raw); | ||
| if (!Number.isFinite(number)) return null; | ||
| return Math.min(Math.max(number, 0), 100); | ||
| } | ||
| // Canonicalizes one `hsl()`/`hsla()` argument list onto the same form | ||
| // `canonicalizeRgb()` produces, so `hsl(0 0% 100%)` meets `#fff` at | ||
| // `#ffffff`—aggressive mode only (rounding-based equivalence) | ||
| function canonicalizeHsl(args) { | ||
| const parts = args.trim().split(/[\s,/]+/); | ||
| if (parts.length < 3 || parts.length > 4) return null; | ||
| // Legacy comma syntax requires percentage saturation/lightness—a unitless | ||
| // `hsl(120,50,50)` is invalid CSS that browsers drop, so it must never | ||
| // compare equal to a valid color (the merge keeps the shortest spelling, | ||
| // which could be the broken one); only the modern space syntax allows the | ||
| // bare-number spelling | ||
| if (args.includes(',') && (!parts[1].endsWith('%') || !parts[2].endsWith('%'))) return null; | ||
| const hue = parseHue(parts[0]); | ||
| const saturation = parseHslComponent(parts[1]); | ||
| const lightness = parseHslComponent(parts[2]); | ||
| if (hue === null || saturation === null || lightness === null) return null; | ||
| const alpha = parts.length === 4 ? parseAlpha(parts[3]) : 1; | ||
| if (alpha === null) return null; | ||
| return formatChannels(hslToRgb(hue, saturation, lightness), alpha); | ||
| } | ||
| // Folds equivalent color spellings onto one canonical form for comparison: | ||
@@ -97,6 +196,9 @@ // `white`, `#fff`, `#ffffff`, `#ffffffff`, `rgb(255, 255, 255)`, and | ||
| // case-sensitive value properties, where `red` could be a counter or | ||
| // keyframes name rather than a color). | ||
| export function normalizeColors(value) { | ||
| return value | ||
| .replace(RE_RGB_FUNCTION, (match, args) => canonicalizeRgb(args) ?? match) | ||
| // keyframes name rather than a color). Only lossless textual equivalences by | ||
| // default; aggressive mode adds the rounding-based ones (`hsl()`, percentage | ||
| // `rgb()` channels). | ||
| export function normalizeColors(value, aggressive = false) { | ||
| let result = value.replace(RE_RGB_FUNCTION, (match, args) => canonicalizeRgb(args, aggressive) ?? match); | ||
| if (aggressive) result = result.replace(RE_HSL_FUNCTION, (match, args) => canonicalizeHsl(args) ?? match); | ||
| return result | ||
| .replace(RE_NAMED_COLOR, name => `#${NAMED_COLORS[name]}`) | ||
@@ -103,0 +205,0 @@ .replace(RE_HEX_COLOR, (match, hex) => ( |
+217
-100
| import postcss from 'postcss'; | ||
| import { normalizeProp, declarationKey } from './normalization.js'; | ||
| import { isIgnoredSelector, resolveIgnorePatterns } from './hacks.js'; | ||
| import { splitSelectors, selectorsAreMutuallyExclusive } from './selectors.js'; | ||
| import { splitSelectors, selectorsAreMutuallyExclusive, selectorsLikelyDisjoint, resetSubjectIdentities } from './selectors.js'; | ||
| import { propertiesOverlap } from './shorthands.js'; | ||
@@ -11,2 +11,18 @@ | ||
| // An anonymous `@layer {}` block is its own, distinct cascade layer—unlike | ||
| // two same-name `@layer x {}` blocks, which share one layer—so each block | ||
| // gets a unique label and never matches another scope. Keyed by node | ||
| // identity (a WeakMap, so labels stay stable across passes within a run and | ||
| // entries fall away with their nodes). | ||
| const layersAnonymous = new WeakMap(); | ||
| let layersAnonymousCount = 0; | ||
| function atRuleScopeSegment(node) { | ||
| if (node.name.toLowerCase() === 'layer' && !node.params.trim()) { | ||
| if (!layersAnonymous.has(node)) layersAnonymous.set(node, ++layersAnonymousCount); | ||
| return `@layer (anonymous ${layersAnonymous.get(node)})`; | ||
| } | ||
| return normalizeScopeSegment(`@${node.name} ${node.params}`); | ||
| } | ||
| // A “scope” is a DRY boundary: the root stylesheet, or the direct contents of | ||
@@ -17,5 +33,8 @@ // one specific `@media`/`@supports`/`@layer`/etc. condition, or one specific | ||
| // and (see `mergeScopesByLabel()` below) to recognize the same boundary | ||
| // when it’s written as two separate physical blocks. Whitespace is | ||
| // normalized here (not case—`@layer` names and selectors can be | ||
| // case-sensitive) so `@media (min-width: 768px)` and | ||
| // when it’s written as two separate physical blocks. The label is always the | ||
| // full ancestor chain: a `.card` nesting host at the root and one inside | ||
| // `@media print` are different boundaries, so a bare-selector label would | ||
| // wrongly identify them (and let aggressive mode merge across the | ||
| // condition). Whitespace is normalized here (not case—`@layer` names and | ||
| // selectors can be case-sensitive) so `@media (min-width: 768px)` and | ||
| // `@media (min-width:768px)` produce the same label regardless of | ||
@@ -25,3 +44,2 @@ // formatting. | ||
| if (container.type === 'root') return 'root'; | ||
| if (container.type === 'rule') return normalizeScopeSegment(container.selector); | ||
@@ -33,3 +51,3 @@ const chain = []; | ||
| ? normalizeScopeSegment(node.selector) | ||
| : normalizeScopeSegment(`@${node.name} ${node.params}`)); | ||
| : atRuleScopeSegment(node)); | ||
| node = node.parent; | ||
@@ -64,3 +82,5 @@ } | ||
| // merged); only `analyzeRoot`, which never moves anything, uses the merged | ||
| // view via `collectMergedScopes()`. | ||
| // view via `collectMergedScopes()`—except in aggressive mode, where | ||
| // `dedupRoot` accepts exactly this risk and merges across same-condition | ||
| // blocks, too. | ||
| function mergeScopesByLabel(scopes) { | ||
@@ -171,2 +191,5 @@ const byLabel = new Map(); | ||
| const ignorePatterns = resolveIgnorePatterns(options); | ||
| const aggressive = options.aggressive ?? false; | ||
| // The normalization mode is bound once per run (see `consolidateRoot()`) | ||
| const keyOf = decl => declarationKey(decl.prop, decl.value, decl.important, aggressive); | ||
| const scopes = collectMergedScopes(root); | ||
@@ -184,3 +207,3 @@ const findings = []; | ||
| for (const decl of rule.nodes.filter(node => node.type === 'decl')) { | ||
| const key = declarationKey(decl.prop, decl.value, decl.important); | ||
| const key = keyOf(decl); | ||
| const occurrence = { rule, decl }; | ||
@@ -248,3 +271,3 @@ | ||
| for (const decl of atrule.nodes.filter(node => node.type === 'decl')) { | ||
| const key = declarationKey(decl.prop, decl.value, decl.important); | ||
| const key = keyOf(decl); | ||
@@ -328,90 +351,63 @@ if (seenInAtrule.has(key)) { | ||
| // A declaration repeated verbatim (after normalization, so `RED`/`red` or | ||
| // `.50`/`.5` count too) within the same rule or the same selector-less | ||
| // at-rule block—`.a { color: red; color: red; }`—is always safe to collapse | ||
| // on its own, unlike the cross-container merge below: Nothing relocates | ||
| // across a rule boundary, so there’s no “intervening rule” risk to check | ||
| // for. Later wins regardless of what’s earlier within one container, so | ||
| // dropping every occurrence but the last never changes which value applies. | ||
| // Runs first, so the cross-container merge pass below only ever sees one | ||
| // occurrence per container per key. | ||
| function removeRedundantDuplicates(container, scopeLabel, selectors) { | ||
| const applied = []; | ||
| const byKey = new Map(); | ||
| // Conditional group rules whose empty block is inert—an empty `@media`, | ||
| // `@supports`, or `@container` block styles nothing and declares nothing. | ||
| // `@layer` is deliberately absent: A layer’s position in the layer order is | ||
| // set by its first appearance, so removing an emptied early `@layer x {}` | ||
| // shell could reorder the cascade. | ||
| const INERT_WHEN_EMPTY_ATRULES = new Set(['media', 'supports', 'container']); | ||
| for (const decl of container.nodes.filter(node => node.type === 'decl')) { | ||
| const key = declarationKey(decl.prop, decl.value, decl.important); | ||
| if (!byKey.has(key)) byKey.set(key, []); | ||
| byKey.get(key).push(decl); | ||
| } | ||
| // Aggressive mode’s cross-block merges consolidate into the last of two | ||
| // same-condition blocks, which can drain the earlier one completely. This | ||
| // removes such emptied blocks—only ones this run emptied (`initiallyEmpty` | ||
| // snapshots the source state), and only where emptiness is provably inert. | ||
| // One walk collects the candidates in document order—parents before | ||
| // children—so the reverse pass below sees each inner block before its | ||
| // parent, and a parent emptied by its child’s removal is caught in the same | ||
| // sweep (no re-walking, no removal during traversal). | ||
| function removeEmptiedConditionBlocks(root, initiallyEmpty) { | ||
| const candidates = []; | ||
| root.walkAtRules(atrule => { | ||
| if (INERT_WHEN_EMPTY_ATRULES.has(atrule.name.toLowerCase())) candidates.push(atrule); | ||
| }); | ||
| for (const [key, decls] of byKey) { | ||
| if (decls.length < 2) continue; | ||
| const last = decls.at(-1); | ||
| // Same “keep whichever raw spelling is shortest” rule as the | ||
| // cross-container merge below | ||
| const shortestValue = decls.reduce((shortest, decl) => ( | ||
| decl.value.length < shortest.length ? decl.value : shortest | ||
| ), decls[0].value); | ||
| if (last.value !== shortestValue) last.value = shortestValue; | ||
| for (const decl of decls) { | ||
| if (decl !== last) decl.remove(); | ||
| } | ||
| applied.push({ scope: scopeLabel, key, redundant: true, selectors, value: shortestValue }); | ||
| for (const atrule of candidates.reverse()) { | ||
| if (atrule.nodes && !atrule.nodes.length && !initiallyEmpty.has(atrule)) atrule.remove(); | ||
| } | ||
| return applied; | ||
| } | ||
| // “True” if a declaration overlapping `propNormalized` (and not itself the | ||
| // declaration matching `excludeKey`) is a candidate “extra”—one that | ||
| // doesn’t participate in the merge but sits close enough to it, within the | ||
| // same rule, to affect the outcome | ||
| function isOverlappingExtra(node, propNormalized, excludeKey) { | ||
| return node.type === 'decl' | ||
| && declarationKey(node.prop, node.value, node.important) !== excludeKey | ||
| && propertiesOverlap(normalizeProp(node.prop), propNormalized); | ||
| } | ||
| // 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 | ||
| // real root—so a growing result leaves the root untouched (“withheld”), | ||
| // which is what lets the PostCSS plugin and the CLI share one | ||
| // implementation of the policy. A withheld result reports `applied: []` and | ||
| // unchanged bytes (that’s what actually happened), with the would-be outcome | ||
| // under `withheld` so callers can explain what was declined. A net-zero | ||
| // result still applies (deduplicated at no byte cost). | ||
| export function dedupRoot(root, options = {}) { | ||
| if (!options.savingsOnly) return consolidateRoot(root, options); | ||
| // Refuse to merge if any other rule sitting between the group’s first and | ||
| // last occurrence also touches this property, or a shorthand/longhand | ||
| // overlapping it (e.g. `margin-left` overlaps `margin`)—for any selector. | ||
| // Moving the declaration past such a rule could change which value wins for | ||
| // whatever that rule matches. Over-cautious by design: It leaves some | ||
| // genuinely safe merges for manual review rather than risk breaking the | ||
| // cascade. | ||
| // | ||
| // One exception: If every one of the intervening rule’s selectors is | ||
| // provably mutually exclusive with every one of the group’s own selectors | ||
| // (see `selectorsAreMutuallyExclusive()`—e.g. `html[lang="da"] a` vs. | ||
| // `html[lang="de"] a`), it can never match an element the group’s rules do, | ||
| // so scanning continues past it for a real blocker. | ||
| // | ||
| // `exemptRules` widens the “is this rule part of the merge” exclusion | ||
| // beyond this one group’s own members to every rule in its entangled | ||
| // cluster (see `mergeCluster()`): A fellow cluster member isn’t a real | ||
| // intervening threat, since it’s being absorbed into the same coordinated | ||
| // merge rather than staying behind. | ||
| function findBlockingRule(scope, distinctRules, exemptRules, firstIndex, lastIndex, propNormalized) { | ||
| const groupSelectors = distinctRules.flatMap(rule => splitSelectors(rule.selector)); | ||
| for (const [index, rule] of scope.rules.entries()) { | ||
| if (index <= firstIndex || index >= lastIndex || exemptRules.has(rule)) continue; | ||
| const conflict = rule.nodes.find(node => node.type === 'decl' && propertiesOverlap(normalizeProp(node.prop), propNormalized)); | ||
| if (!conflict) continue; | ||
| const clone = root.clone(); | ||
| const result = consolidateRoot(clone, options); | ||
| if (result.bytes.saved < 0) { | ||
| return { | ||
| applied: [], | ||
| skipped: result.skipped, | ||
| bytes: { before: result.bytes.before, after: result.bytes.before, saved: 0 }, | ||
| withheld: { count: result.applied.length, bytes: result.bytes }, | ||
| }; | ||
| } | ||
| const candidateSelectors = splitSelectors(rule.selector); | ||
| const provablyDisjoint = candidateSelectors.every(candidateSelector => ( | ||
| groupSelectors.every(groupSelector => selectorsAreMutuallyExclusive(candidateSelector, groupSelector)) | ||
| )); | ||
| if (provablyDisjoint) continue; | ||
| return { rule, prop: normalizeProp(conflict.prop) }; | ||
| if (result.applied.length) { | ||
| root.raws = clone.raws; | ||
| root.removeAll(); | ||
| root.append(clone.nodes); | ||
| } | ||
| return null; | ||
| return result; | ||
| } | ||
| export function dedupRoot(root, options = {}) { | ||
| function consolidateRoot(root, options = {}) { | ||
| // The subject-identity memoization is per run: fresh here, reused across | ||
| // this run’s fixed-point passes, never carried over to the next stylesheet | ||
| resetSubjectIdentities(); | ||
| // Taken before any mutation, so it reflects the file as it stood on disk— | ||
@@ -423,2 +419,3 @@ // byte counts, not character counts, since the effectiveness this measures | ||
| const ignorePatterns = resolveIgnorePatterns(options); | ||
| const aggressive = options.aggressive ?? false; | ||
| const multilineSelectors = usesMultilineSelectors(root); | ||
@@ -428,2 +425,116 @@ const applied = []; | ||
| // Cross-block merges (aggressive mode) can drain a physical block | ||
| // completely; blocks that were already empty in the source are recorded | ||
| // here so the cleanup at the end only ever removes what this run emptied | ||
| const initiallyEmpty = new Set(); | ||
| if (aggressive) { | ||
| root.walkAtRules(atrule => { | ||
| if (atrule.nodes && !atrule.nodes.length) initiallyEmpty.add(atrule); | ||
| }); | ||
| } | ||
| // The normalization mode is bound once per run: Everything below keys and | ||
| // compares declarations through these two, so no call site can fall back | ||
| // to default-mode normalization by forgetting a flag—which would silently | ||
| // give the same declaration two different keys in different phases of an | ||
| // aggressive run | ||
| const keyOf = decl => declarationKey(decl.prop, decl.value, decl.important, aggressive); | ||
| const propOf = prop => normalizeProp(prop, aggressive); | ||
| // A declaration repeated verbatim (after normalization, so `RED`/`red` or | ||
| // `.50`/`.5` count too) within the same rule or the same selector-less | ||
| // at-rule block—`.a { color: red; color: red; }`—is always safe to collapse | ||
| // on its own, unlike the cross-container merges below: Nothing relocates | ||
| // across a rule boundary, so there’s no “intervening rule” risk to check | ||
| // for. Later wins regardless of what’s earlier within one container, so | ||
| // dropping every occurrence but the last never changes which value applies. | ||
| // Runs first, so the cross-container merge passes below only ever see one | ||
| // occurrence per container per key. | ||
| function removeRedundantDuplicates(container, scopeLabel, selectors) { | ||
| const collapsed = []; | ||
| const byKey = new Map(); | ||
| for (const decl of container.nodes.filter(node => node.type === 'decl')) { | ||
| const key = keyOf(decl); | ||
| if (!byKey.has(key)) byKey.set(key, []); | ||
| byKey.get(key).push(decl); | ||
| } | ||
| for (const [key, decls] of byKey) { | ||
| if (decls.length < 2) continue; | ||
| const last = decls.at(-1); | ||
| // Same “keep whichever raw spelling is shortest” rule as the | ||
| // cross-container merges below | ||
| const shortestValue = decls.reduce((shortest, decl) => ( | ||
| decl.value.length < shortest.length ? decl.value : shortest | ||
| ), decls[0].value); | ||
| if (last.value !== shortestValue) last.value = shortestValue; | ||
| for (const decl of decls) { | ||
| if (decl !== last) decl.remove(); | ||
| } | ||
| collapsed.push({ scope: scopeLabel, key, redundant: true, selectors, value: shortestValue }); | ||
| } | ||
| return collapsed; | ||
| } | ||
| // “True” if a declaration overlapping `propNormalized` (and not itself the | ||
| // declaration matching `excludeKey`) is a candidate “extra”—one that | ||
| // doesn’t participate in the merge but sits close enough to it, within the | ||
| // same rule, to affect the outcome | ||
| function isOverlappingExtra(node, propNormalized, excludeKey) { | ||
| return node.type === 'decl' | ||
| && keyOf(node) !== excludeKey | ||
| && propertiesOverlap(propOf(node.prop), propNormalized); | ||
| } | ||
| // Refuse to merge if any other rule sitting between the group’s first and | ||
| // last occurrence also touches this property, or a shorthand/longhand | ||
| // overlapping it (e.g. `margin-left` overlaps `margin`)—for any selector. | ||
| // Moving the declaration past such a rule could change which value wins for | ||
| // whatever that rule matches. Over-cautious by design: It leaves some | ||
| // genuinely safe merges for manual review rather than risk breaking the | ||
| // cascade. | ||
| // | ||
| // One exception: If every one of the intervening rule’s selectors is | ||
| // provably mutually exclusive with every one of the group’s own selectors | ||
| // (see `selectorsAreMutuallyExclusive()`—e.g. `html[lang="da"] a` vs. | ||
| // `html[lang="de"] a`), it can never match an element the group’s rules do, | ||
| // so scanning continues past it for a real blocker. Aggressive mode widens | ||
| // this to selectors that are merely likely disjoint (see | ||
| // `selectorsLikelyDisjoint()`—subject compounds sharing no class/ID/type), | ||
| // trading the proof for what BEM-style class naming makes almost always | ||
| // true in practice. | ||
| // | ||
| // `exemptRules` widens the “is this rule part of the merge” exclusion | ||
| // beyond this one group’s own members to every rule in its entangled | ||
| // cluster (see `mergeCluster()`): A fellow cluster member isn’t a real | ||
| // intervening threat, since it’s being absorbed into the same coordinated | ||
| // merge rather than staying behind. | ||
| function findBlockingRule(scope, distinctRules, exemptRules, firstIndex, lastIndex, propNormalized) { | ||
| const groupSelectors = distinctRules.flatMap(rule => splitSelectors(rule.selector)); | ||
| for (const [index, rule] of scope.rules.entries()) { | ||
| if (index <= firstIndex || index >= lastIndex || exemptRules.has(rule)) continue; | ||
| const conflict = rule.nodes.find(node => node.type === 'decl' && propertiesOverlap(propOf(node.prop), propNormalized)); | ||
| if (!conflict) continue; | ||
| const candidateSelectors = splitSelectors(rule.selector); | ||
| // The (memoized, cheap) heuristic goes first: In aggressive mode it | ||
| // clears most pairs, saving the exclusivity proof’s full selector parse | ||
| const disjoint = candidateSelectors.every(candidateSelector => ( | ||
| groupSelectors.every(groupSelector => ( | ||
| (aggressive && selectorsLikelyDisjoint(candidateSelector, groupSelector)) | ||
| || selectorsAreMutuallyExclusive(candidateSelector, groupSelector) | ||
| )) | ||
| )); | ||
| if (disjoint) continue; | ||
| return { rule, prop: propOf(conflict.prop) }; | ||
| } | ||
| return null; | ||
| } | ||
| // Folds rules repeating the same selector (list) within one scope into | ||
@@ -465,3 +576,3 @@ // the last of them—`.a { color: red; } … .a { margin: 0; }` becomes one | ||
| for (const decl of rule.nodes) { | ||
| blocking = findBlockingRule(scope, [rule, target], exempt, ruleIndex, targetIndex, normalizeProp(decl.prop)); | ||
| blocking = findBlockingRule(scope, [rule, target], exempt, ruleIndex, targetIndex, propOf(decl.prop)); | ||
| if (blocking) break; | ||
@@ -527,3 +638,3 @@ } | ||
| if (rule === target) { | ||
| const isExtra = node => node.type === 'decl' && declarationKey(node.prop, node.value, node.important) !== key; | ||
| const isExtra = node => node.type === 'decl' && keyOf(node) !== key; | ||
| const afterExtras = rule.nodes.filter((node, index) => index > sharedIndex && isExtra(node)); | ||
@@ -564,3 +675,3 @@ const beforeExtras = rule.nodes.filter((node, index) => index < sharedIndex && isExtra(node)); | ||
| for (const decl of rule.nodes.filter(node => node.type === 'decl')) { | ||
| if (declarationKey(decl.prop, decl.value, decl.important) === key) decl.remove(); | ||
| if (keyOf(decl) === key) decl.remove(); | ||
| } | ||
@@ -681,3 +792,3 @@ } | ||
| const allShared = rule.nodes.every(node => ( | ||
| node.type === 'decl' && clusterKeys.has(declarationKey(node.prop, node.value, node.important)) | ||
| node.type === 'decl' && clusterKeys.has(keyOf(node)) | ||
| )); | ||
@@ -688,3 +799,3 @@ if (!allShared) return false; | ||
| const sequences = rules.map(rule => ( | ||
| rule.nodes.map(node => declarationKey(node.prop, node.value, node.important)).join('\n') | ||
| rule.nodes.map(node => keyOf(node)).join('\n') | ||
| )); | ||
@@ -710,3 +821,3 @@ const sameOrder = sequences.every(sequence => sequence === sequences[0]); | ||
| for (const decl of target.nodes) { | ||
| const key = declarationKey(decl.prop, decl.value, decl.important); | ||
| const key = keyOf(decl); | ||
| const group = cluster.find(candidate => candidate.key === key); | ||
@@ -753,3 +864,3 @@ const shortestValue = group.occurrences.reduce((shortest, occ) => ( | ||
| return !rule.nodes.some((node, index) => ( | ||
| index > declIndex && node.type === 'decl' && propertiesOverlap(normalizeProp(node.prop), propNormalized) | ||
| index > declIndex && node.type === 'decl' && propertiesOverlap(propOf(node.prop), propNormalized) | ||
| )); | ||
@@ -997,3 +1108,3 @@ }; | ||
| for (const decl of rule.nodes.filter(node => node.type === 'decl')) { | ||
| const key = declarationKey(decl.prop, decl.value, decl.important); | ||
| const key = keyOf(decl); | ||
| if (!byKey.has(key)) byKey.set(key, []); | ||
@@ -1009,3 +1120,3 @@ byKey.get(key).push({ rule, decl }); | ||
| if (distinctRules.length < 2) continue; | ||
| groups.push({ key, occurrences, distinctRules, propNormalized: normalizeProp(occurrences[0].decl.prop) }); | ||
| groups.push({ key, occurrences, distinctRules, propNormalized: propOf(occurrences[0].decl.prop) }); | ||
| } | ||
@@ -1108,3 +1219,7 @@ | ||
| const scopes = collectScopes(root); | ||
| // Aggressive mode merges same-condition blocks into one scope, accepting | ||
| // that rules from other scopes sitting between the blocks stay invisible | ||
| // to the intervening-rule check; default mode keeps one scope per | ||
| // physical container (see `mergeScopesByLabel()`) | ||
| const scopes = aggressive ? collectMergedScopes(root) : collectScopes(root); | ||
| for (const scope of scopes) { | ||
@@ -1122,2 +1237,4 @@ for (const rule of eligibleRules(scope, ignorePatterns)) { | ||
| if (aggressive) removeEmptiedConditionBlocks(root, initiallyEmpty); | ||
| const after = Buffer.byteLength(root.toString(), 'utf8'); | ||
@@ -1129,4 +1246,4 @@ return { applied, skipped, bytes: { before, after, saved: before - after } }; | ||
| const root = postcss.parse(css, { from: options.from }); | ||
| const { applied, skipped, bytes } = dedupRoot(root, options); | ||
| return { css: root.toString(), applied, skipped, bytes }; | ||
| } | ||
| const result = dedupRoot(root, options); | ||
| return { css: root.toString(), ...result }; | ||
| } |
+21
-7
@@ -151,7 +151,21 @@ import { normalizeColors } from './colors.js'; | ||
| export function normalizeProp(prop) { | ||
| // Property aliases—legacy names current browsers treat as pure synonyms of | ||
| // their standardized successors. Only folded in aggressive mode: The two | ||
| // spellings are interchangeable today, but merging them changes the | ||
| // legacy-support surface (a browser old enough to know only `word-wrap` | ||
| // loses the declaration when the `overflow-wrap` spelling is the one kept). | ||
| const PROPERTY_ALIASES = { | ||
| 'word-wrap': 'overflow-wrap', | ||
| 'grid-gap': 'gap', | ||
| 'grid-row-gap': 'row-gap', | ||
| 'grid-column-gap': 'column-gap', | ||
| }; | ||
| export function normalizeProp(prop, aggressive = false) { | ||
| const trimmed = prop.trim(); | ||
| // Custom property names are case-sensitive (`--Foo` !== `--foo`); every | ||
| // other CSS property name is ASCII-case-insensitive | ||
| return trimmed.startsWith('--') ? trimmed : trimmed.toLowerCase(); | ||
| if (trimmed.startsWith('--')) return trimmed; | ||
| const lower = trimmed.toLowerCase(); | ||
| return aggressive ? PROPERTY_ALIASES[lower] ?? lower : lower; | ||
| } | ||
@@ -178,5 +192,5 @@ | ||
| export function normalizeValue(prop, rawValue) { | ||
| export function normalizeValue(prop, rawValue, aggressive = false) { | ||
| let value = rawValue.trim(); | ||
| const propNormalized = normalizeProp(prop); | ||
| const propNormalized = normalizeProp(prop, aggressive); | ||
@@ -207,3 +221,3 @@ // Custom property values are opaque end to end: They substitute verbatim | ||
| value = value.replace(/(^|[\s(,])\+(?=[\d.])/g, '$1'); | ||
| if (!CASE_SENSITIVE_VALUE_PROPS.has(propNormalized)) value = normalizeColors(value); | ||
| if (!CASE_SENSITIVE_VALUE_PROPS.has(propNormalized)) value = normalizeColors(value, aggressive); | ||
| // `bold`/`700` and `normal`/`400` are defined equal—only for the | ||
@@ -230,4 +244,4 @@ // longhand, though; picking the weight out of the `font` shorthand would | ||
| export function declarationKey(prop, value, important) { | ||
| return `${normalizeProp(prop)}: ${normalizeValue(prop, value)}${important ? ' !important' : ''}`; | ||
| export function declarationKey(prop, value, important, aggressive = false) { | ||
| return `${normalizeProp(prop, aggressive)}: ${normalizeValue(prop, value, aggressive)}${important ? ' !important' : ''}`; | ||
| } |
+4
-1
@@ -13,3 +13,6 @@ import { analyzeRoot, dedupRoot } from './index.js'; | ||
| if (options.fix) { | ||
| const { skipped } = dedupRoot(root, options); | ||
| const { skipped, withheld } = dedupRoot(root, options); | ||
| 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`); | ||
| } | ||
| for (const item of skipped) { | ||
@@ -16,0 +19,0 @@ root.warn(result, `Duplicate \`${item.key}\` left unmerged (${item.scope === 'root' ? 'root' : item.scope}): ${item.reason}`); |
+82
-0
@@ -155,2 +155,84 @@ // Splits a selector list on top-level commas only, respecting commas nested | ||
| // A type selector at the start of a compound (`div`, `input`—never `*`) | ||
| const RE_TYPE_SELECTOR = /^[a-zA-Z][\w-]*/; | ||
| // The identity tokens—type, IDs, classes—of a selector’s subject compound | ||
| // (its rightmost one). Returns “null” when the compound can’t be read | ||
| // confidently: An escape could hide a `.`/`#` behind content, and a | ||
| // selector-taking pseudo-class (`:is()`, `:not()`, `:where()`, …) can smuggle | ||
| // in arbitrary further identity—both fall back to “can’t tell” rather than | ||
| // risk a wrong disjointness call. | ||
| // | ||
| // Memoized, and scoped to one consolidation run (see | ||
| // `resetSubjectIdentities()`): The merge-safety scan asks about the same | ||
| // selectors over and over within a run, but a long-lived process (a PostCSS | ||
| // watch build, say) must not accumulate every selector—think generated or | ||
| // hashed class names—it has ever seen. | ||
| const subjectIdentities = new Map(); | ||
| // Called at the start of each top-level `dedupRoot()` run—the only flow that | ||
| // reaches `subjectIdentity()` | ||
| export function resetSubjectIdentities() { | ||
| subjectIdentities.clear(); | ||
| } | ||
| function subjectIdentity(selector) { | ||
| if (subjectIdentities.has(selector)) return subjectIdentities.get(selector); | ||
| const identity = computeSubjectIdentity(selector); | ||
| subjectIdentities.set(selector, identity); | ||
| return identity; | ||
| } | ||
| function computeSubjectIdentity(selector) { | ||
| const scan = scanSelector(selector); | ||
| const lastRun = scan.combinatorRuns.at(-1); | ||
| const compound = selector.slice(lastRun ? lastRun.end : 0); | ||
| // `|` marks a namespace (`svg|rect`, `[xlink|href=…]`)—neither the type | ||
| // regex nor the attribute-selector regex understands those, so reading on | ||
| // would fabricate identity (`svg|rect` as type `svg`, an attribute value’s | ||
| // `.zzz` as a class); like escapes and parens, that’s a “can’t tell” | ||
| if (compound.includes('\\') || compound.includes('(') || compound.includes('|')) return null; | ||
| // Attribute selectors go first—their values can contain `.`/`#` characters | ||
| // that would otherwise read as classes/IDs | ||
| const stripped = compound.replace(RE_ATTRIBUTE_SELECTOR, ' '); | ||
| const type = RE_TYPE_SELECTOR.exec(compound)?.[0].toLowerCase() ?? null; | ||
| const classes = new Set(Array.from(stripped.matchAll(/\.([\w-]+)/g), match => match[1])); | ||
| const ids = new Set(Array.from(stripped.matchAll(/#([\w-]+)/g), match => match[1])); | ||
| return { type, classes, ids }; | ||
| } | ||
| // No allocation—this runs per selector pair on the aggressive merge-safety | ||
| // hot path; iterating the smaller set keeps the lookups on the cheap side | ||
| function setsDisjoint(a, b) { | ||
| const [small, large] = a.size <= b.size ? [a, b] : [b, a]; | ||
| for (const member of small) { | ||
| if (large.has(member)) return false; | ||
| } | ||
| return true; | ||
| } | ||
| // “True” if the two selectors’ subject compounds carry conflicting identity: | ||
| // different type selectors, different IDs, or non-empty class sets with no | ||
| // class in common. The type and ID cases are close to provable (one element | ||
| // has one tag and one ID); The class case is the aggressive-mode heuristic— | ||
| // `.card` and `.btn:hover` are assumed to target different elements, which | ||
| // BEM-style naming makes almost always true in practice, but which nothing | ||
| // stops a `class="card btn"` element from violating. Not consulted outside | ||
| // aggressive mode. | ||
| export function selectorsLikelyDisjoint(a, b) { | ||
| const identityA = subjectIdentity(a.trim()); | ||
| const identityB = subjectIdentity(b.trim()); | ||
| if (!identityA || !identityB) return false; | ||
| if (identityA.type && identityB.type && identityA.type !== identityB.type) return true; | ||
| if (identityA.ids.size && identityB.ids.size && setsDisjoint(identityA.ids, identityB.ids)) return true; | ||
| if (identityA.classes.size && identityB.classes.size && setsDisjoint(identityA.classes, identityB.classes)) return true; | ||
| return false; | ||
| } | ||
| // The “an attribute can only hold one value” argument requires the two | ||
@@ -157,0 +239,0 @@ // differing attribute selectors to be evaluated against the same element in |
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
141047
24.6%2245
20.83%234
12.5%3
50%