+335
-77
@@ -71,3 +71,3 @@ #!/usr/bin/env node | ||
| if (positionals.includes('-') && positionals.length > 1) { | ||
| console.error('Cannot combine stdin (`-`) with other file arguments.'); | ||
| console.error('Cannot combine STDIN (`-`) with other file arguments.'); | ||
| process.exit(1); | ||
@@ -185,19 +185,132 @@ } | ||
| function formatBytesSummary({ before, after, saved }) { | ||
| const percent = before ? (saved / before) * 100 : 0; | ||
| // A byte magnitude with a signed percentage—“-” for savings, “+” for | ||
| // growth—for the bulleted summaries, where the sign carries the direction | ||
| // instead of a surrounding sentence. `more: true` marks the amount as | ||
| // additional on top of a total already stated elsewhere (the `--aggressive` | ||
| // bullets, which quote what aggressive mode adds beyond plain `--fix`). | ||
| function formatByteMagnitude(bytesAbs, before, sign, { more = false } = {}) { | ||
| const percent = before ? (bytesAbs / before) * 100 : 0; | ||
| return `${bytesAbs.toLocaleString()} ${more ? 'more bytes' : 'bytes'} (${sign}${percent.toFixed(1)}%)`; | ||
| } | ||
| // One summary bullet’s outcome clause: “save N bytes (-P%)” or “grow by N | ||
| // bytes (+P%)”—always still-hypothetical phrasing (report mode’s base | ||
| // bullet, or an `--aggressive` preview under either mode), since the one | ||
| // bullet describing an already-applied `--fix` change has its own clause | ||
| function formatByteDeltaClause(saved, before, more = false) { | ||
| const sign = saved >= 0 ? '-' : '+'; | ||
| return `${before.toLocaleString()} → ${after.toLocaleString()} bytes (${sign}${Math.abs(saved).toLocaleString()} B, ${sign}${Math.abs(percent).toFixed(1)}%)`; | ||
| const magnitude = formatByteMagnitude(Math.abs(saved), before, sign, { more }); | ||
| if (saved >= 0) return `save ${magnitude}`; | ||
| return `grow by ${magnitude}`; | ||
| } | ||
| // 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) { | ||
| // A single-file bullet’s full outcome clause: “Reduce duplication and | ||
| // save …” when it pays off, “but grow …” when it doesn’t | ||
| function formatReduceClause(saved, before, more = false) { | ||
| const conjunction = saved >= 0 ? 'and' : 'but'; | ||
| return `Reduce duplication ${conjunction} ${formatByteDeltaClause(saved, before, more)}`; | ||
| } | ||
| // The fix-mode consolidated bullet’s clause: The concrete before → after | ||
| // byte counts fold into the parenthetical (replacing the separate | ||
| // before → after line printed elsewhere), and the conjunction is always | ||
| // “and”—“but grew” read as though the growth undercut the “reduced | ||
| // duplication” result, when `--fix` applies both changes regardless | ||
| function formatAppliedReduceClause(bytes) { | ||
| const sign = bytes.saved >= 0 ? '-' : '+'; | ||
| const percent = bytes.before ? (Math.abs(bytes.saved) / bytes.before) * 100 : 0; | ||
| return `${Math.abs(bytes.saved).toLocaleString()} bytes (${percent.toFixed(1)}%)`; | ||
| const magnitude = `${Math.abs(bytes.saved).toLocaleString()} bytes (${bytes.before.toLocaleString()} → ${bytes.after.toLocaleString()} bytes, ${sign}${percent.toFixed(1)}%)`; | ||
| const verb = bytes.saved >= 0 ? `saved ${magnitude}` : `grew by ${magnitude}`; | ||
| return `Reduced duplication and ${verb}`; | ||
| } | ||
| // The mixed-results net—shrinking files’ savings minus growing files’ | ||
| // growth—against the run’s combined original size, spelled out as the | ||
| // literal “for overall ±N bytes / ±P%” the summary bullets quote in | ||
| // parentheses | ||
| function formatOverallNet(net, totalBefore) { | ||
| const percent = totalBefore ? (Math.abs(net) / totalBefore) * 100 : 0; | ||
| const sign = net >= 0 ? '-' : '+'; | ||
| return `for overall ${sign}${Math.abs(net).toLocaleString()} bytes / ${sign}${percent.toFixed(1)}%`; | ||
| } | ||
| // The one outcome bullet the all-files summary prints twice—once for the | ||
| // base count, once for what `--aggressive` adds—covering its three possible | ||
| // shapes: Every file in the split shrinks, every file grows, or the split | ||
| // is mixed. `tense` is `'done'` for `--fix` (already applied, no flag to | ||
| // suggest, no “but”—the growth already happened alongside the shrinkage, | ||
| // not despite it, so the mixed shape reads as a plain gerund list: “…, | ||
| // shrinking … and growing …”) or `'todo'` for report mode or an | ||
| // `--aggressive` preview (a recommendation, flag named, “but” contrasts the | ||
| // still-open choice). `more` marks the `--aggressive` bullets, whose totals | ||
| // are additional on top of the base bullet printed just above. | ||
| function formatOutcomeBullet({ countLabel, tense, filesShrinkLen, shrinkTotal, filesGrowLen, growTotal, totalBefore, flag, skipFlag, more = false }) { | ||
| const s = n => n !== 1 ? 's' : ''; | ||
| const reduce = tense === 'done' ? 'Reduced' : 'Reduce'; | ||
| const flagClause = tense === 'done' ? '' : ` with \`${flag}\``; | ||
| if (filesShrinkLen && !filesGrowLen) { | ||
| const saved = tense === 'done' ? 'saved' : 'save'; | ||
| return [`* ${countLabel}: ${reduce} duplication and ${saved} ${formatByteMagnitude(shrinkTotal, totalBefore, '-', { more })}${flagClause}`]; | ||
| } | ||
| if (!filesShrinkLen && filesGrowLen) { | ||
| const grew = tense === 'done' ? 'grew' : 'grow'; | ||
| const conjunction = tense === 'done' ? 'and' : 'but'; | ||
| return [`* ${countLabel}: ${reduce} duplication ${conjunction} ${grew} by ${formatByteMagnitude(growTotal, totalBefore, '+', { more })}${flagClause}`]; | ||
| } | ||
| if (filesShrinkLen && filesGrowLen) { | ||
| const net = shrinkTotal - growTotal; | ||
| if (tense === 'done') { | ||
| // A gerund list, not “and shrink … but grow …”: `--fix` already | ||
| // applied both changes in the same run, so there’s no contrast left | ||
| // to draw—just what happened, itemized | ||
| const first = `* ${countLabel}: Reduced duplication, shrinking ${filesShrinkLen} file${s(filesShrinkLen)} by ${formatByteMagnitude(shrinkTotal, totalBefore, '-', { more })} and growing ${filesGrowLen} file${s(filesGrowLen)} by ${formatByteMagnitude(growTotal, totalBefore, '+', { more })} (${formatOverallNet(net, totalBefore)})`; | ||
| return [first]; | ||
| } | ||
| const first = `* ${countLabel}: Reduce duplication and shrink ${filesShrinkLen} file${s(filesShrinkLen)} by ${formatByteMagnitude(shrinkTotal, totalBefore, '-', { more })} but grow ${filesGrowLen} file${s(filesGrowLen)} by ${formatByteMagnitude(growTotal, totalBefore, '+', { more })}${flagClause} (${formatOverallNet(net, totalBefore)})`; | ||
| const second = ` - Skip files that grow in size to save ${formatByteMagnitude(shrinkTotal, totalBefore, '-')} in total with \`${skipFlag}\``; | ||
| return [first, second]; | ||
| } | ||
| return null; | ||
| } | ||
| // What `--aggressive` would add on top of this run’s real outcome, measured | ||
| // against a discarded opposite-mode pass (`potential`)—shared by `--fix` | ||
| // and report mode, which differ only in which CSS string and `bytes` they | ||
| // compare it against (the written output vs. a discarded dry run) | ||
| function computeAggressivePreview(potential, resultCss, applied, bytes) { | ||
| const aggDiffers = Boolean(potential && potential.css !== resultCss); | ||
| if (!aggDiffers) return { aggExtra: 0, aggExtraSaved: 0, aggDiffers: false }; | ||
| return { | ||
| aggExtra: potential.applied.length - applied.length, | ||
| aggExtraSaved: potential.bytes.saved - bytes.saved, | ||
| aggDiffers: true, | ||
| }; | ||
| } | ||
| // The “in aggressive mode” preview bullet, shared by `--fix` and report | ||
| // mode—always still-hypothetical, so always `formatReduceClause()`’s | ||
| // present-tense phrasing even inside a `--fix` run | ||
| function formatAggressivePreviewLine(aggExtra, aggExtraSaved, before) { | ||
| return `* ${aggExtra > 0 ? `${aggExtra} more finding${aggExtra !== 1 ? 's' : ''}` : 'Further consolidation'} in aggressive mode: ${formatReduceClause(aggExtraSaved, before, true)} with \`--fix --aggressive\``; | ||
| } | ||
| // The per-file stats object the overall summary aggregates across a | ||
| // multi-file run—one shape shared by all three of `processCss()`’s return | ||
| // sites (the zero-findings shortcut, `--fix` mode, and report mode) | ||
| function buildStats({ findings, applied, skipped, bytes, withheld, aggExtra, aggExtraSaved, aggDiffers }) { | ||
| return { | ||
| findings, | ||
| applied: applied.length, | ||
| skipped: skipped.length, | ||
| bytesBefore: bytes.before, | ||
| bytesSaved: bytes.saved, | ||
| withheldCount: withheld ? withheld.count : 0, | ||
| withheldGrowth: withheld ? Math.abs(withheld.bytes.saved) : 0, | ||
| aggExtra, | ||
| aggExtraSaved, | ||
| aggDiffers, | ||
| }; | ||
| } | ||
| function printFindings(findings) { | ||
@@ -239,5 +352,13 @@ const grouped = new Map(); | ||
| // Processes one target (a file path, or `-` for STDIN) and returns whether | ||
| // it should count against the process’s exit code. In `--fix` mode, STDIN | ||
| // is a special case: There is no file to rewrite in place, so the | ||
| // A file that never made it into `stats` (read/parse failure): excluded | ||
| // from the overall-summary totals, but counted so the totals can note how | ||
| // many files that summary doesn’t speak for | ||
| const RESULT_ERRORED = { exitFailure: true, errored: true, stats: null }; | ||
| // Processes one target (a file path, or `-` for STDIN) and returns | ||
| // `{ exitFailure, errored, stats }`: `exitFailure` is whether it should | ||
| // count against the process’s exit code, `errored` whether it never | ||
| // produced stats (read/parse failure), and `stats` the per-file numbers the | ||
| // overall summary aggregates across a multi-file run. In `--fix` mode, | ||
| // STDIN is a special case: There is no file to rewrite in place, so the | ||
| // consolidated CSS is printed to STDOUT instead—and, so that stream stays | ||
@@ -255,3 +376,3 @@ // pipeable, the usual status/summary lines go to STDERR rather than STDOUT. | ||
| console.error(styleText('red', `Could not read ${label}: ${preread.err.message}`)); | ||
| return true; | ||
| return RESULT_ERRORED; | ||
| } | ||
@@ -264,3 +385,3 @@ css = preread.css; | ||
| console.error(styleText('red', `Could not read ${label}: ${err.message}`)); | ||
| return true; | ||
| return RESULT_ERRORED; | ||
| } | ||
@@ -274,3 +395,3 @@ } | ||
| try { | ||
| return await processCss(css, targetOptions, { isStdin, label }); | ||
| return await processCss(css, targetOptions, { isStdin, label, multi }); | ||
| } catch (err) { | ||
@@ -283,3 +404,3 @@ if (err.name === 'CssSyntaxError') { | ||
| } | ||
| return true; | ||
| return RESULT_ERRORED; | ||
| } | ||
@@ -329,3 +450,16 @@ } | ||
| async function processCss(css, targetOptions, { isStdin, label }) { | ||
| // The skipped-group detail block, printed before the summary in both | ||
| // `--fix` and report mode—`log` is `console.log` in report mode, and in | ||
| // `--fix` mode either `console.log` or `console.error` depending on | ||
| // whether STDOUT needs to stay clear for piped CSS output | ||
| function logSkippedDetail(log, skipped, skippedAggressive) { | ||
| if (!skipped.length) return; | ||
| log(styleText('yellow', `${skipped.length} duplicate group${skipped.length !== 1 ? 's' : ''} considered unsafe to auto-merge:`)); | ||
| for (const item of skipped) { | ||
| log(formatSkippedLine(item, skippedAggressive)); | ||
| } | ||
| log(''); | ||
| } | ||
| async function processCss(css, targetOptions, { isStdin, label, multi }) { | ||
| const potential = targetOptions.aggressive ? null : oppositePass(css, targetOptions); | ||
@@ -340,2 +474,6 @@ const skippedAggressive = skippedWithAggressive(potential); | ||
| const log = isStdin ? console.error : console.log; | ||
| // A multi-file run’s overall summary needs each file’s label restated on | ||
| // its own summary line—by the time the run ends, the header this file | ||
| // printed at the top of its report may already be out of scrollback | ||
| const summaryLabel = multi ? `Summary for ${label}: ` : ''; | ||
@@ -368,26 +506,32 @@ // Whether anything actually rode on the flag—measured by comparing | ||
| // the same order report mode already uses for its own skipped list | ||
| if (skipped.length) { | ||
| log(styleText('yellow', `${skipped.length} duplicate group${skipped.length !== 1 ? 's' : ''} considered unsafe to auto-merge:`)); | ||
| for (const item of skipped) { | ||
| log(formatSkippedLine(item, skippedAggressive)); | ||
| } | ||
| log(''); | ||
| } | ||
| logSkippedDetail(log, skipped, skippedAggressive); | ||
| const skippedNote = skipped.length ? ' (considered unsafe to auto-merge)' : ''; | ||
| log(multi ? styleText('bold', summaryLabel.trim()) : 'Summary:'); | ||
| // Every remaining line is its own bullet, in the order a reader wants | ||
| // them: the outcome first, then the run’s own footnotes on it (growth | ||
| // caveat, aggressive-only warning, where it was written), then the two | ||
| // forward-looking items (what was skipped, what `--aggressive` adds) | ||
| 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}`); | ||
| log(`* 0 declarations consolidated, ${withheld.count} withheld: \`savingsOnly\` left this file untouched—consolidating would ${formatByteDeltaClause(withheld.bytes.saved, withheld.bytes.before)}`); | ||
| } else { | ||
| log(`${styleText('green', `${applied.length} consolidated`)}, ${styleText('yellow', `${skipped.length} skipped`)}${skippedNote}`); | ||
| log(`* ${applied.length} declaration${applied.length !== 1 ? 's' : ''} consolidated${applied.length ? `: ${formatAppliedReduceClause(bytes)}` : ''}`); | ||
| } | ||
| if (applied.length) { | ||
| log(`\n${formatBytesSummary(bytes)}`); | ||
| if (bytes.saved < 0) { | ||
| 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.`)); | ||
| log('* Worth it for maintainability (each declaration used once); skip `--fix` here if you care more about transfer size.'); | ||
| } | ||
| if (!isStdin) log(`Wrote ${label}`); | ||
| 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.`)); | ||
| } | ||
| 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.`)); | ||
| log(styleText('yellow', `* ${isStdin ? 'This style sheet' : label} references a source map (\`sourceMappingURL\`); \`--fix\` doesn’t regenerate it, so the map is now stale.`)); | ||
| } | ||
| } | ||
| if (skipped.length) { | ||
| log(styleText('yellow', `* ${skipped.length} finding${skipped.length !== 1 ? 's' : ''} skipped (considered unsafe to auto-merge)`)); | ||
| } | ||
| // What `--aggressive` would actually change on disk, measured against | ||
@@ -398,17 +542,10 @@ // this run’s real outcome: `potential` went through the same | ||
| // 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.`)); | ||
| } | ||
| const { aggExtra, aggExtraSaved, aggDiffers } = computeAggressivePreview(potential, output, applied, bytes); | ||
| if (aggDiffers) log(formatAggressivePreviewLine(aggExtra, aggExtraSaved, bytes.before)); | ||
| return skipped.length > 0 || Boolean(withheld); | ||
| return { | ||
| exitFailure: skipped.length > 0 || Boolean(withheld), | ||
| errored: false, | ||
| stats: buildStats({ findings: null, applied, skipped, bytes, withheld, aggExtra, aggExtraSaved, aggDiffers }), | ||
| }; | ||
| } | ||
@@ -423,3 +560,16 @@ | ||
| console.log(`No duplicate declarations found.${note}`); | ||
| return false; | ||
| return { | ||
| exitFailure: false, | ||
| errored: false, | ||
| stats: buildStats({ | ||
| findings: 0, | ||
| applied: [], | ||
| skipped: [], | ||
| bytes: { before: Buffer.byteLength(css, 'utf8'), saved: 0 }, | ||
| withheld: null, | ||
| aggExtra: potential?.applied.length ?? 0, | ||
| aggExtraSaved: potential?.bytes.saved ?? 0, | ||
| aggDiffers: Boolean(potential?.applied.length), | ||
| }), | ||
| }; | ||
| } | ||
@@ -437,13 +587,9 @@ | ||
| // concrete, explainable reason it wasn't offered as a `--fix` win | ||
| if (skipped.length) { | ||
| console.log(styleText('yellow', `${skipped.length} duplicate group${skipped.length !== 1 ? 's' : ''} considered unsafe to auto-merge:`)); | ||
| for (const item of skipped) { | ||
| console.log(formatSkippedLine(item, skippedAggressive)); | ||
| } | ||
| console.log(''); | ||
| } | ||
| logSkippedDetail(console.log, skipped, skippedAggressive); | ||
| // Summary and `--fix` payoff close each style sheet’s report, so with | ||
| // several files it’s unambiguous which file they refer to | ||
| console.log(`${styleText('bold', 'Summary:')} ${findings.length} finding${findings.length !== 1 ? 's' : ''}`); | ||
| // Summary and `--fix` payoff close each style sheet’s report. With a | ||
| // single file that’s unambiguous on its own; with several, the filename | ||
| // is restated here, too—by the time a long multi-file run ends, the | ||
| // per-file header above may already be out of scrollback. | ||
| console.log(styleText('bold', multi ? `Summary for ${label}:` : 'Summary:')); | ||
| if (withheld) { | ||
@@ -453,8 +599,7 @@ // The dry run went through the engine’s `savingsOnly` gate (set via the | ||
| // 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.`)); | ||
| console.log(`* ${findings.length} finding${findings.length !== 1 ? 's' : ''}: \`savingsOnly\` leaves this file untouched—consolidating would ${formatByteDeltaClause(withheld.bytes.saved, withheld.bytes.before)} with \`--fix\``); | ||
| } else if (applied.length) { | ||
| if (bytes.saved > 0) { | ||
| 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 ${formatBytesShare(bytes)} bigger, not smaller (worth it for maintainability but not for transfer size).`)); | ||
| console.log(`* ${findings.length} finding${findings.length !== 1 ? 's' : ''}: ${formatReduceClause(bytes.saved, bytes.before)} with \`--fix\``); | ||
| if (bytes.saved < 0) { | ||
| console.log(styleText('dim', ' (worth it for maintainability, not for transfer size)')); | ||
| } | ||
@@ -466,13 +611,121 @@ } | ||
| // 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}.`); | ||
| const { aggExtra, aggExtraSaved, aggDiffers } = computeAggressivePreview(potential, cssDryRun, applied, bytes); | ||
| if (aggDiffers) console.log(formatAggressivePreviewLine(aggExtra, aggExtraSaved, bytes.before)); | ||
| return { | ||
| exitFailure: true, | ||
| errored: false, | ||
| stats: buildStats({ findings: findings.length, applied, skipped, bytes, withheld, aggExtra, aggExtraSaved, aggDiffers }), | ||
| }; | ||
| } | ||
| function sumBy(list, fn) { | ||
| return list.reduce((total, item) => total + fn(item), 0); | ||
| } | ||
| // A byte magnitude against the overall summary’s own total original size | ||
| // rather than one file’s—there’s no single “this file” to express a | ||
| // percentage of once several files’ byte deltas are combined. The explicit | ||
| // “overall” avoids a mismatch reading as an error: A file’s own summary a | ||
| // few lines up already showed this same byte count against a different | ||
| // (smaller) denominator—its own size, not the whole run’s. | ||
| function formatBytesShareOfTotal(bytesAbs, totalBefore) { | ||
| const percent = totalBefore ? (bytesAbs / totalBefore) * 100 : 0; | ||
| return `${bytesAbs.toLocaleString()} bytes (${percent.toFixed(1)}% overall)`; | ||
| } | ||
| // Rolls up every file’s `stats` into one closing report, so a terminal that | ||
| // only shows the last N lines of a multi-file run doesn’t leave the final | ||
| // file’s own summary looking like it spoke for the whole run | ||
| function printOverallSummary(results, { fix }) { | ||
| const ok = results.filter(result => result.stats); | ||
| const errored = results.length - ok.length; | ||
| const erroredNote = errored ? ` (${errored} file${errored !== 1 ? 's' : ''} could not be processed; see errors above)` : ''; | ||
| console.log(''); | ||
| // Every percentage below is against this—the combined original size of | ||
| // every successfully processed file—since there’s no single file left to | ||
| // relate a byte count to once the run’s totals are combined | ||
| const totalBeforeAll = sumBy(ok, result => result.stats.bytesBefore); | ||
| const filesShrink = ok.filter(result => result.stats.bytesSaved > 0); | ||
| const filesGrow = ok.filter(result => result.stats.bytesSaved < 0); | ||
| const shrinkTotal = sumBy(filesShrink, result => result.stats.bytesSaved); | ||
| const growTotal = Math.abs(sumBy(filesGrow, result => result.stats.bytesSaved)); | ||
| const withheldFiles = ok.filter(result => result.stats.withheldCount > 0); | ||
| const withheldGrowthTotal = sumBy(withheldFiles, result => result.stats.withheldGrowth); | ||
| const aggFiles = ok.filter(result => result.stats.aggDiffers); | ||
| const aggFilesShrink = aggFiles.filter(result => result.stats.aggExtraSaved > 0); | ||
| const aggFilesGrow = aggFiles.filter(result => result.stats.aggExtraSaved < 0); | ||
| const aggShrinkTotal = sumBy(aggFilesShrink, result => result.stats.aggExtraSaved); | ||
| const aggGrowTotal = Math.abs(sumBy(aggFilesGrow, result => result.stats.aggExtraSaved)); | ||
| console.log(styleText('bold', `Summary for all files:${erroredNote}`)); | ||
| if (fix) { | ||
| const totalApplied = sumBy(ok, result => result.stats.applied); | ||
| const totalSkipped = sumBy(ok, result => result.stats.skipped); | ||
| const outcome = formatOutcomeBullet({ | ||
| countLabel: `${totalApplied} declaration${totalApplied !== 1 ? 's' : ''} consolidated`, | ||
| tense: 'done', | ||
| filesShrinkLen: filesShrink.length, | ||
| shrinkTotal, | ||
| filesGrowLen: filesGrow.length, | ||
| growTotal, | ||
| totalBefore: totalBeforeAll, | ||
| skipFlag: '--fix --savings-only', | ||
| }); | ||
| if (outcome) { | ||
| for (const line of outcome) console.log(line); | ||
| } | ||
| if (totalSkipped) { | ||
| console.log(styleText('yellow', `* ${totalSkipped} finding${totalSkipped !== 1 ? 's' : ''} skipped (considered unsafe to auto-merge)`)); | ||
| } | ||
| if (withheldFiles.length) { | ||
| console.log(`* ${withheldFiles.length} file${withheldFiles.length !== 1 ? 's' : ''} left untouched by \`--savings-only\`—consolidating would have made ${withheldFiles.length !== 1 ? 'them' : 'it'} ${formatBytesShareOfTotal(withheldGrowthTotal, totalBeforeAll)} bigger in total`); | ||
| } | ||
| } else { | ||
| if (withheldFiles.length) { | ||
| console.log(`\`--fix\` would leave ${withheldFiles.length} file${withheldFiles.length !== 1 ? 's' : ''} untouched—\`savingsOnly\` is set, and consolidating would make ${withheldFiles.length !== 1 ? 'them' : 'it'} ${formatBytesShareOfTotal(withheldGrowthTotal, totalBeforeAll)} bigger.`); | ||
| } | ||
| const totalFindings = sumBy(ok, result => result.stats.findings); | ||
| const outcome = formatOutcomeBullet({ | ||
| countLabel: `${totalFindings} finding${totalFindings !== 1 ? 's' : ''}`, | ||
| tense: 'todo', | ||
| filesShrinkLen: filesShrink.length, | ||
| shrinkTotal, | ||
| filesGrowLen: filesGrow.length, | ||
| growTotal, | ||
| totalBefore: totalBeforeAll, | ||
| flag: '--fix', | ||
| skipFlag: '--fix --savings-only', | ||
| }); | ||
| if (outcome) { | ||
| for (const line of outcome) console.log(line); | ||
| } | ||
| } | ||
| return true; | ||
| // Always a preview (never yet applied), regardless of whether the base | ||
| // run above was `--fix` or report mode—so this block, unlike the two | ||
| // above, doesn’t vary by `fix` and only needs to run once | ||
| if (aggFiles.length) { | ||
| const extra = sumBy(aggFiles, result => result.stats.aggExtra); | ||
| const aggOutcome = formatOutcomeBullet({ | ||
| countLabel: `${extra > 0 ? `${extra} more finding${extra !== 1 ? 's' : ''}` : 'Further consolidation'} in aggressive mode`, | ||
| tense: 'todo', | ||
| filesShrinkLen: aggFilesShrink.length, | ||
| shrinkTotal: aggShrinkTotal, | ||
| filesGrowLen: aggFilesGrow.length, | ||
| growTotal: aggGrowTotal, | ||
| totalBefore: totalBeforeAll, | ||
| flag: '--fix --aggressive', | ||
| skipFlag: '--fix --aggressive --savings-only', | ||
| more: true, | ||
| }); | ||
| if (aggOutcome) { | ||
| for (const line of aggOutcome) console.log(line); | ||
| } | ||
| } | ||
| } | ||
@@ -509,10 +762,15 @@ | ||
| let failed = false; | ||
| const results = []; | ||
| for (const [index, file] of files.entries()) { | ||
| // Two blank lines between per-file reports, so each file’s closing | ||
| // summary is visually separated from the next file’s header | ||
| if (multi && index > 0) console.log('\n'); | ||
| if (await processTarget(file, options, { multi }, prefetched[index])) failed = true; | ||
| // A blank line between per-file reports, so each file’s closing summary | ||
| // is visually separated from the next file’s header | ||
| if (multi && index > 0) console.log(''); | ||
| const result = await processTarget(file, options, { multi }, prefetched[index]); | ||
| if (result.exitFailure) failed = true; | ||
| results.push(result); | ||
| } | ||
| if (multi) printOverallSummary(results, { fix: values.fix }); | ||
| if (failed) process.exitCode = 1; | ||
@@ -519,0 +777,0 @@ } |
+1
-1
@@ -64,3 +64,3 @@ { | ||
| "types": "src/index.d.ts", | ||
| "version": "1.3.3" | ||
| "version": "1.4.0" | ||
| } |
+7
-7
@@ -94,3 +94,3 @@ # CSS Dedup, the CSS Declaration Deduplicator | ||
| ```javascript | ||
| ```js | ||
| // css-dedup.config.js | ||
@@ -112,3 +112,3 @@ export default { | ||
| ```javascript | ||
| ```js | ||
| import { analyze, dedup } from 'css-dedup'; | ||
@@ -124,3 +124,3 @@ | ||
| ```javascript | ||
| ```js | ||
| { | ||
@@ -137,5 +137,5 @@ from: 'path/to/file.css', // forwarded to PostCSS; names the file in syntax-error messages | ||
| ```javascript | ||
| ```js | ||
| { | ||
| scope, // `root`, or the at-rule chain the rules live in, e.g. `@media (min-width: 768px)` | ||
| scope, // `root`, or the at-rule chain the rules live in, e.g., `@media (min-width: 768px)` | ||
| key, // normalized `prop: value` (plus ` !important` if set) | ||
@@ -157,3 +157,3 @@ redundant, // “true” if the same declaration repeats within one rule, absent otherwise | ||
| ```javascript | ||
| ```js | ||
| import postcss from 'postcss'; | ||
@@ -209,3 +209,3 @@ import cssdedup from 'css-dedup/plugin'; | ||
| - Then, a duplicate group spread across separate rules is merged by folding its selectors into the last occurrence—one line per selector if that’s already how the file writes multi-selector rules, comma-separated on one line otherwise. | ||
| - Keeps whichever of the group’s equivalent raw spellings is shortest (e.g. `.5` over `0.50`)—CSS Dedup only picks among spellings already present in the source, so it doesn’t synthesize a shorter one, which would be a minifier’s job. | ||
| - Keeps whichever of the group’s equivalent raw spellings is shortest (e.g., `.5` over `0.50`)—CSS Dedup only picks among spellings already present in the source, so it doesn’t synthesize a shorter one, which would be a minifier’s job. | ||
| - 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. | ||
@@ -212,0 +212,0 @@ - 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. |
+4
-4
@@ -545,3 +545,3 @@ import postcss from 'postcss'; | ||
| // last occurrence also touches this property, or a shorthand/longhand | ||
| // overlapping it (e.g. `margin-left` overlaps `margin`)—for any selector. | ||
| // overlapping it (e.g., `margin-left` overlaps `margin`)—for any selector. | ||
| // Moving the declaration past such a rule could change which value wins for | ||
@@ -554,3 +554,3 @@ // whatever that rule matches. Over-cautious by design: It leaves some | ||
| // provably mutually exclusive with every one of the group’s own selectors | ||
| // (see `selectorsAreMutuallyExclusive()`—e.g. `html[lang="da"] a` vs. | ||
| // (see `selectorsAreMutuallyExclusive()`—e.g., `html[lang="da"] a` vs. | ||
| // `html[lang="de"] a`), it can never match an element the group’s rules do, | ||
@@ -685,3 +685,3 @@ // so scanning continues past it for a real blocker. Aggressive mode widens | ||
| // inserted after target instead needs the file’s normal between-rules | ||
| // separator, not target’s own (which can be anomalous, e.g. a comment | ||
| // separator, not target’s own (which can be anomalous, e.g., a comment | ||
| // sitting flush above it). | ||
@@ -1065,3 +1065,3 @@ const interPieceSeparator = typicalSeparator(target.parent); | ||
| // between-rules separator instead, not the hub’s own (which can be | ||
| // anomalous, e.g. a comment sitting flush above it). Also has to be | ||
| // anomalous, e.g., a comment sitting flush above it). Also has to be | ||
| // reapplied *after* `.after()` runs, not just before it: for a rule | ||
@@ -1068,0 +1068,0 @@ // sitting directly in the root (not inside an @-rule/nested rule), |
@@ -385,3 +385,3 @@ import { normalizeColors } from './colors.js'; | ||
| // Both unit conversions above produce their own freshly-scaled decimal | ||
| // text (e.g. `0.3s` → `300.0ms`), so the general decimal cleanup runs | ||
| // 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, | ||
@@ -388,0 +388,0 @@ // so two arguments that are the same value in different raw spellings |
+1
-1
| import { analyzeRoot, dedupRoot } from './index.js'; | ||
| // A thin PostCSS plugin wrapper around `analyzeRoot`/`dedupRoot`, for | ||
| // dropping CSS Dedup into an existing `postcss([…])` pipeline (e.g. alongside | ||
| // dropping CSS Dedup into an existing `postcss([…])` pipeline (e.g., alongside | ||
| // Autoprefixer, cssnano) instead of running it as a separate file-based pass. | ||
@@ -6,0 +6,0 @@ // By default it only reports, via `result.warn()`—pass `fix: true` to |
| // Maps each shorthand property to the longhands it sets—physical and | ||
| // logical alike, but not physical ↔ logical pairs (e.g. `margin-left` vs | ||
| // logical alike, but not physical ↔ logical pairs (e.g., `margin-left` vs | ||
| // `margin-inline-start`): which physical side a logical longhand resolves to | ||
@@ -4,0 +4,0 @@ // depends on the element’s writing mode/direction, which isn’t knowable from |
177802
8.17%2893
9.01%